Respect the selected agent credential file

Keep PULSE_TOKEN out of the direct-token flag default. Refuse an unreadable or empty selected file instead of silently borrowing other credentials, while preserving enrolled runtime-token restarts and implicit token-optional startup.

Cover actual flag loading and a synthetic host issuer, private installer-writer, compiled agent and trusted HTTPS lookup/report journey. This repairs an independent source-selection defect; it does not establish the cause or recovery of Discussion #2769.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-11 18:28:08 +01:00
parent bd38f26a50
commit 75a2a34855
5 changed files with 503 additions and 22 deletions
+33 -19
View File
@@ -1336,7 +1336,9 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
// Flags
fs := flag.NewFlagSet("pulse-agent", flag.ContinueOnError)
urlFlag := fs.String("url", envURL, "Pulse server URL")
tokenFlag := fs.String("token", envToken, "Pulse API token (prefer --token-file for security)")
// Keep the environment fallback separate from an explicit flag, otherwise
// PULSE_TOKEN would masquerade as --token and shadow --token-file.
tokenFlag := fs.String("token", "", "Pulse API token (prefer --token-file for security)")
tokenFileFlag := fs.String("token-file", "", "Path to file containing Pulse API token (more secure than --token)")
intervalFlag := fs.Duration("interval", defaultInterval, "Reporting interval")
hostnameFlag := fs.String("hostname", envHostname, "Override hostname")
@@ -1419,16 +1421,11 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
agentIDFile = filepath.Join(stateDir, "agent-id")
}
// Resolve token with priority: --token > --token-file > env > state-dir file.
token := resolveToken(*tokenFlag, *tokenFileFlag, envToken, stateDir)
observers, err := agenttarget.LoadObservers(strings.TrimSpace(*observersFileFlag), pulseURL)
if err != nil {
return Config{}, fmt.Errorf("load observer destinations: %w", err)
}
// When --enroll is set and a runtime token already exists from a previous
// enrollment, use it instead of the bootstrap token embedded in the service
// config. This ensures the agent survives process and server restarts.
// config. Resolve it first so a removed bootstrap file does not prevent a
// previously enrolled agent from restarting.
var token string
if *enrollFlag {
runtimeTokenPath := filepath.Join(stateDir, "runtime.token")
if content, err := os.ReadFile(runtimeTokenPath); err == nil {
@@ -1437,6 +1434,18 @@ func loadConfig(args []string, getenv func(string) string) (Config, error) {
}
}
}
if token == "" {
// Resolve token with priority: --token > --token-file > env > state-dir file.
var err error
token, err = resolveToken(*tokenFlag, *tokenFileFlag, envToken, stateDir)
if err != nil {
return Config{}, err
}
}
observers, err := agenttarget.LoadObservers(strings.TrimSpace(*observersFileFlag), pulseURL)
if err != nil {
return Config{}, fmt.Errorf("load observer destinations: %w", err)
}
if token == "" && *enrollFlag && !*selfTest {
return Config{}, fmt.Errorf("Pulse API token is required for enrollment (use --token, --token-file, PULSE_TOKEN env, or %s)", defaultTokenFilePath())
@@ -1668,7 +1677,7 @@ func resolveEnableCommands(enableFlag, disableFlag bool, envEnable, envDisable s
// 4. Token file under the resolved state directory
//
// Reading from a file is more secure than CLI args as tokens won't appear in `ps` output.
func resolveToken(tokenFlag, tokenFileFlag, envToken, stateDir string) string {
func resolveToken(tokenFlag, tokenFileFlag, envToken, stateDir string) (string, error) {
return resolveTokenInternal(tokenFlag, tokenFileFlag, envToken, stateDir, os.ReadFile)
}
@@ -1689,24 +1698,29 @@ func defaultTokenFilePath() string {
return filepath.Join(defaultAgentStateDir(), "token")
}
func resolveTokenInternal(tokenFlag, tokenFileFlag, envToken, stateDir string, readFile func(string) ([]byte, error)) string {
func resolveTokenInternal(tokenFlag, tokenFileFlag, envToken, stateDir string, readFile func(string) ([]byte, error)) (string, error) {
// 1. Direct token from --token flag
if t := strings.TrimSpace(tokenFlag); t != "" {
return t
return t, nil
}
// 2. Token from --token-file flag
if tokenFileFlag != "" {
if content, err := readFile(tokenFileFlag); err == nil {
if t := strings.TrimSpace(string(content)); t != "" {
return t
}
content, err := readFile(tokenFileFlag)
if err != nil {
// A selected source must not silently become some other credential
// (or an unauthenticated agent). Do not include path or file contents.
return "", fmt.Errorf("cannot read --token-file; check that the configured file exists and is readable by the agent service")
}
if t := strings.TrimSpace(string(content)); t != "" {
return t, nil
}
return "", fmt.Errorf("--token-file is empty; provide a file containing only the API token")
}
// 3. PULSE_TOKEN environment variable
if t := strings.TrimSpace(envToken); t != "" {
return t
return t, nil
}
// 4. Token file in the resolved state directory. When stateDir is custom,
@@ -1717,11 +1731,11 @@ func resolveTokenInternal(tokenFlag, tokenFileFlag, envToken, stateDir string, r
}
if content, err := readFile(tokenFile); err == nil {
if t := strings.TrimSpace(string(content)); t != "" {
return t
return t, nil
}
}
return ""
return "", nil
}
// initModuleWithRetry attempts to initialize an agent module with exponential
+112 -3
View File
@@ -903,7 +903,10 @@ func TestResolveToken(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := resolveTokenInternal(tc.tokenFlag, tc.tokenFileFlag, tc.envToken, tc.stateDir, fakeReadFile)
got, err := resolveTokenInternal(tc.tokenFlag, tc.tokenFileFlag, tc.envToken, tc.stateDir, fakeReadFile)
if err != nil {
t.Fatal(err)
}
if got != tc.expected {
t.Fatalf("%s: expected %q, got %q", tc.name, tc.expected, got)
}
@@ -911,20 +914,126 @@ func TestResolveToken(t *testing.T) {
}
t.Run("truly empty", func(t *testing.T) {
got := resolveTokenInternal("", "", "", customStateDir, fakeReadFileNoDefault)
got, err := resolveTokenInternal("", "", "", customStateDir, fakeReadFileNoDefault)
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Fatalf("expected empty, got %q", got)
}
})
t.Run("custom state never borrows default token", func(t *testing.T) {
got := resolveTokenInternal("", "", "", filepath.Join(string(filepath.Separator), "missing-custom"), fakeReadFile)
got, err := resolveTokenInternal("", "", "", filepath.Join(string(filepath.Separator), "missing-custom"), fakeReadFile)
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Fatalf("custom state unexpectedly borrowed default token %q", got)
}
})
}
// Exercise flag parsing as well as the resolver: a resolver-only test cannot
// detect an environment value accidentally installed as the --token default.
func TestLoadConfigTokenSources(t *testing.T) {
for _, tc := range []struct {
name string
args []string
envToken string
fileToken string
runtimeToken string
want string
wantError string
}{
{name: "selected file beats environment", args: []string{"--token-file", "FILE"}, envToken: "environment-token", fileToken: "file-token", want: "file-token"},
{name: "selected file without environment", args: []string{"--token-file=FILE"}, fileToken: "file-token", want: "file-token"},
{name: "single dash file beats environment", args: []string{"-token-file", "FILE"}, envToken: "environment-token", fileToken: "file-token", want: "file-token"},
{name: "explicit token beats file and environment", args: []string{"--token-file", "FILE", "--token", "argument-token"}, envToken: "environment-token", fileToken: "file-token", want: "argument-token"},
{name: "explicit token does not read lower priority file", args: []string{"--token", "argument-token", "--token-file", "MISSING"}, envToken: "environment-token", want: "argument-token"},
{name: "empty token flag does not mask selected file", args: []string{"--token=", "--token-file", "FILE"}, envToken: "environment-token", fileToken: "file-token", want: "file-token"},
{name: "environment beats implicit state file", envToken: "environment-token", want: "environment-token"},
{name: "implicit custom state file", want: "state-token"},
{name: "outer whitespace remains accepted", args: []string{"--token-file", "FILE"}, fileToken: " \tfile-token\r\n", want: "file-token"},
{name: "quotes are not part of the file format", args: []string{"--token-file", "FILE"}, fileToken: `"file-token"`, want: `"file-token"`},
{name: "assignment is not decoded as a credential", args: []string{"--token-file", "FILE"}, fileToken: "PULSE_TOKEN=file-token", want: "PULSE_TOKEN=file-token"},
{name: "missing selected file refuses environment and state fallback", args: []string{"--token-file", "MISSING"}, envToken: "environment-token", wantError: "cannot read --token-file"},
{name: "directory selected as a file refuses fallback", args: []string{"--token-file", "DIRECTORY"}, envToken: "environment-token", wantError: "cannot read --token-file"},
{name: "empty selected file refuses fallback", args: []string{"--token-file", "FILE"}, envToken: "environment-token", wantError: "--token-file is empty"},
{name: "whitespace selected file refuses fallback", args: []string{"--token-file", "FILE"}, fileToken: " \t\r\n", envToken: "environment-token", wantError: "--token-file is empty"},
{name: "enrollment restart keeps runtime credential", args: []string{"--enroll", "--token-file", "FILE", "--token", "argument-token"}, envToken: "environment-token", fileToken: "bootstrap-token", runtimeToken: "runtime-token\n", want: "runtime-token"},
{name: "enrollment restart survives removed bootstrap file", args: []string{"--enroll", "--token-file", "MISSING"}, runtimeToken: "runtime-token", want: "runtime-token"},
{name: "first enrollment still uses selected bootstrap", args: []string{"--enroll", "--token-file", "FILE"}, envToken: "environment-token", fileToken: "bootstrap-token", want: "bootstrap-token"},
{name: "empty runtime credential does not mask file failure", args: []string{"--enroll", "--token-file", "MISSING"}, runtimeToken: " \n", wantError: "cannot read --token-file"},
} {
t.Run(tc.name, func(t *testing.T) {
stateDir := t.TempDir()
file := filepath.Join(stateDir, "selected-token")
for name, content := range map[string]string{"token": "state-token", "selected-token": tc.fileToken} {
if err := os.WriteFile(filepath.Join(stateDir, name), []byte(content), 0600); err != nil {
t.Fatal(err)
}
}
if tc.runtimeToken != "" {
if err := os.WriteFile(filepath.Join(stateDir, "runtime.token"), []byte(tc.runtimeToken), 0600); err != nil {
t.Fatal(err)
}
}
args := []string{"--url", "https://pulse.example.test", "--state-dir", stateDir, "--agent-id", "retained-agent-id", "--command-authority", "monitoring-only"}
for _, arg := range tc.args {
arg = strings.ReplaceAll(arg, "FILE", file)
arg = strings.ReplaceAll(arg, "MISSING", filepath.Join(stateDir, "missing-token"))
arg = strings.ReplaceAll(arg, "DIRECTORY", stateDir)
args = append(args, arg)
}
cfg, err := loadConfig(args, func(key string) string {
if key == "PULSE_TOKEN" {
return tc.envToken
}
return ""
})
if tc.wantError != "" {
if err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("expected actionable selected-file error, got %v", err)
}
for _, private := range []string{stateDir, "environment-token", "state-token", tc.fileToken} {
if private != "" && strings.Contains(err.Error(), private) {
t.Fatal("selected-file error disclosed private configuration")
}
}
if cfg.APIToken != "" {
t.Fatal("failed configuration retained a fallback credential")
}
return
}
if err != nil {
t.Fatal(err)
}
if cfg.APIToken != tc.want {
t.Fatal("wrong credential source selected")
}
if cfg.EnableCommands || cfg.InsecureSkipVerify || !cfg.EnableHost || cfg.AgentID != "retained-agent-id" || cfg.StateDir != stateDir || cfg.CommandAuthorityProfile != hostagent.CommandAuthorityMonitoringOnly {
t.Fatal("credential selection changed identity, trust or monitoring-only authority")
}
})
}
}
func TestResolveTokenSelectedFileReadErrorIsPrivate(t *testing.T) {
calls := 0
readFile := func(path string) ([]byte, error) {
calls++
return nil, &os.PathError{Op: "open", Path: path, Err: os.ErrPermission}
}
got, err := resolveTokenInternal("", "private-path-with-token-value", "environment-token", "/implicit-state", readFile)
if err == nil || got != "" || calls != 1 {
t.Fatal("selected-file failure fell through to another credential source")
}
if strings.Contains(err.Error(), "private-path") || strings.Contains(err.Error(), "token-value") {
t.Fatal("selected-file error disclosed the configured path")
}
}
func TestCleanupDockerAgent(t *testing.T) {
t.Run("nil agent does nothing", func(t *testing.T) {
cleanupDockerAgent(nil, &zerolog.Logger{})
@@ -15,6 +15,30 @@
## Purpose
### Selected agent credential files retain their own authority
The real agent flag-loading path resolves a non-empty explicit `--token` before
`--token-file`, then `PULSE_TOKEN`, then the resolved instance's implicit token
file. An environment fallback must not become the direct flag's default and
shadow the operator's selected file. A selected unreadable or empty file stops
startup with a credential-safe error rather than borrowing another credential
or starting unauthenticated. Implicit token-optional startup remains supported.
With enrolment enabled, an existing non-empty `runtime.token` still takes
precedence and permits restart even when the bootstrap file has been removed.
Token contents remain opaque: surrounding whitespace is trimmed, but quotes
and shell assignments are not interpreted or repaired into credentials.
`TestLoadConfigTokenSources` covers actual flag parsing, source priority,
selected-file failure, custom state, enrolment, identity, TLS verification and
monitoring-only command ceilings. `TestResolveTokenSelectedFileReadErrorIsPrivate`
checks that file errors expose neither the configured path nor its contents.
`TestHostAgentCredentialFileHandoff` joins the authenticated host issuer, actual
installer token-writer function, private file modes, compiled agent and trusted
HTTPS lookup/report routes. It checks normal process restart, credential
last-use, identity continuity, environment non-use and adverse representations.
That synthetic process journey does not exercise the terminal prompt, complete
privileged installer, a reporter's configuration or installed recovery.
A legacy client echoing the projected `controlled` mode into an unrelated AI
settings PUT must retain a stored `autonomous` preference and its existing
legacy command-approval selection. `TestAssistantProjectedModeEchoPreservesLegacyPolicy`
@@ -18,6 +18,24 @@
## Purpose
### Explicit agent credential-file failure does not borrow authority
The `cmd/pulse-agent/main.go` flag loader keeps `PULSE_TOKEN` separate from an
explicit `--token` so an environment value cannot shadow `--token-file`. A
selected unreadable or empty credential file stops startup without disclosing
the file path or contents and without substituting environment/state authority.
Non-empty direct tokens retain first priority; implicit token-optional startup
and enrolled runtime-token restart remain supported. Opaque credential contents
are not interpreted as shell assignments or unquoted on the operator's behalf.
Actual flag-loading/error controls are `TestLoadConfigTokenSources` and
`TestResolveTokenSelectedFileReadErrorIsPrivate`. The synthetic trusted-HTTPS
`TestHostAgentCredentialFileHandoff` also checks no request on selected-file
failure, no environment credential use, no token in agent logs, preserved
monitoring-only scopes/commands and ordinary process identity continuity.
Neither this local journey nor a credential's inventory entry establishes
authentication or recovery of an unobserved installed agent.
### Infrastructure credentials remain within the configured HTTP origin
The shared `tlsutil.CreateHTTPClientWithTimeout` transport permits redirects
@@ -0,0 +1,316 @@
//go:build unix
package api
import (
"bytes"
"context"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
)
// This is an offline, synthetic process journey, not an installed-service or
// reporter recovery test. It joins the actual host issuer, installer token
// writer, agent CLI/transport and authenticated server routes. The terminal
// prompt and the rest of the privileged installer are outside this seam.
func TestHostAgentCredentialFileHandoff(t *testing.T) {
root, err := filepath.Abs(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
binary := filepath.Join(t.TempDir(), "pulse-agent")
buildCtx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
build := exec.CommandContext(buildCtx, "go", "build", "-o", binary, "./cmd/pulse-agent")
build.Dir = root
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build agent from this source: %v\n%s", err, output)
}
for _, tc := range []struct {
name string
environment bool
format string
wantStatus int
startupErr bool
}{
{name: "file only", wantStatus: http.StatusOK},
{name: "file over environment", environment: true, wantStatus: http.StatusOK},
{name: "outer whitespace", format: "whitespace", wantStatus: http.StatusOK},
{name: "quoted credential", format: "quoted", wantStatus: http.StatusUnauthorized},
{name: "assignment credential", format: "assignment", wantStatus: http.StatusUnauthorized},
{name: "different bare credential", format: "different", wantStatus: http.StatusUnauthorized},
{name: "empty file refuses fallback", environment: true, format: "empty", startupErr: true},
{name: "missing file refuses fallback", environment: true, format: "missing", startupErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
adminRaw := "synthetic-handoff-admin.12345678"
envRaw := "synthetic-handoff-environment.12345678"
admin := newTokenRecord(t, adminRaw, []string{config.ScopeWildcard}, nil)
envRecord := newTokenRecord(t, envRaw, []string{config.ScopeAgentReport, config.ScopeAgentConfigRead}, nil)
runtime := newHostRemovalLifecycleHTTPRuntime(t, t.TempDir(), []config.APITokenRecord{admin, envRecord})
t.Cleanup(runtime.stop)
issued := serveHostRemovalLifecycleRequest(t, runtime, http.MethodPost, "/api/agent-install-command", adminRaw,
[]byte(`{"type":"host","enableCommands":false,"name":"credential-handoff"}`))
install := decodeHostAgentInstallResponse(t, issued)
if install.Token == "" || install.Record == nil {
t.Fatal("host issuer did not return a scoped credential")
}
assertHandoffCredentialUse(t, runtime.config, install.Record.ID, false)
config.Mu.RLock()
for _, record := range runtime.config.APITokens {
if record.ID == install.Record.ID && (record.HasScope(config.ScopeAgentExec) || !record.HasScope(config.ScopeAgentReport) || !record.HasScope(config.ScopeAgentConfigRead)) {
t.Error("host issuer changed monitoring-only scopes")
}
}
config.Mu.RUnlock()
events := make(chan handoffHTTPEvent, 64)
var requests atomic.Int64
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
event := handoffHTTPEvent{path: r.URL.Path}
if strings.HasSuffix(r.URL.Path, "/report") {
body, readErr := io.ReadAll(io.LimitReader(r.Body, 4<<20))
if readErr != nil {
t.Error("could not observe synthetic report")
}
r.Body = io.NopCloser(bytes.NewReader(body))
if err := json.Unmarshal(body, &event.report); err != nil {
t.Error("could not decode synthetic report")
}
}
rec := httptest.NewRecorder()
runtime.router.Handler().ServeHTTP(rec, r)
for key, values := range rec.Header() {
w.Header()[key] = values
}
w.WriteHeader(rec.Code)
_, _ = w.Write(rec.Body.Bytes())
event.status = rec.Code
if strings.HasSuffix(event.path, "/lookup") || strings.HasSuffix(event.path, "/report") {
select {
case events <- event:
default:
t.Error("synthetic HTTP event buffer exceeded")
}
}
}))
t.Cleanup(server.Close)
state := t.TempDir()
tokenFile := persistHandoffToken(t, root, state, install.Token)
if tc.format != "" {
content := install.Token
switch tc.format {
case "whitespace":
content = " \t" + content + "\r\n"
case "quoted":
content = `"` + content + `"`
case "assignment":
content = "PULSE_TOKEN=" + content
case "different":
content = "different-synthetic-token"
case "empty", "missing":
content = ""
}
// Adverse file representations are deliberate fixture edits, not
// claims that the generator or installer writes these formats.
if err := os.WriteFile(tokenFile, []byte(content), 0600); err != nil {
t.Fatal(err)
}
if tc.format == "missing" {
if err := os.Remove(tokenFile); err != nil {
t.Fatal(err)
}
}
}
caFile := filepath.Join(t.TempDir(), "synthetic-server-ca.pem")
if err := os.WriteFile(caFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0600); err != nil {
t.Fatal(err)
}
environment := ""
if tc.environment {
environment = envRaw
}
runs := 1
if tc.wantStatus == http.StatusOK {
runs = 2 // Same state, file and identity after an ordinary restart.
}
for run := 0; run < runs; run++ {
stop, exit, logs := startHandoffAgent(t, binary, server.URL, caFile, tokenFile, state, environment)
if tc.startupErr {
select {
case err := <-exit:
if err == nil || !strings.Contains(logs.String(), "--token-file") {
t.Fatal("invalid selected file did not produce a startup error")
}
case <-time.After(15 * time.Second):
t.Fatal("invalid selected file did not stop startup")
}
if requests.Load() != 0 {
t.Fatal("invalid selected file sent an HTTP request using fallback authority")
}
} else {
awaitHandoffHTTP(t, events, tc.wantStatus, run > 0)
}
stop()
if !tc.startupErr {
if err := <-exit; err != nil {
t.Fatal("synthetic agent did not stop cleanly")
}
}
if strings.Contains(logs.String(), install.Token) || strings.Contains(logs.String(), envRaw) {
t.Fatal("agent logs disclosed a credential")
}
}
assertHandoffCredentialUse(t, runtime.config, install.Record.ID, tc.wantStatus == http.StatusOK)
assertHandoffCredentialUse(t, runtime.config, envRecord.ID, false)
if tc.wantStatus == http.StatusOK && len(runtime.monitor.GetState().Hosts) != 1 {
t.Fatal("process restart duplicated the host identity")
}
t.Logf("runs=%d report_status=%d startup_refused=%v selected_record_used=%v environment_record_used=false", runs, tc.wantStatus, tc.startupErr, tc.wantStatus == http.StatusOK)
})
}
}
type handoffHTTPEvent struct {
path string
status int
report agentshost.Report
}
func awaitHandoffHTTP(t *testing.T, events <-chan handoffHTTPEvent, wantStatus int, restarted bool) {
t.Helper()
lookup, report := false, false
deadline := time.NewTimer(30 * time.Second)
defer deadline.Stop()
for !lookup || !report {
select {
case event := <-events:
if strings.HasSuffix(event.path, "/lookup") {
lookup = true
// A new valid identity is not registered until its first report.
if event.status != wantStatus && !(wantStatus == http.StatusOK && !restarted && event.status == http.StatusNotFound) {
t.Fatalf("lookup status = %d, want authenticated lookup or new-identity absence", event.status)
}
} else {
report = true
if event.status != wantStatus {
t.Fatalf("report status = %d, want %d", event.status, wantStatus)
}
if event.report.Agent.ID != "credential-handoff-agent" || event.report.Host.Hostname != "credential-handoff.local" || event.report.Agent.CommandsEnabled {
t.Fatal("report changed selected identity or command authority")
}
}
case <-deadline.C:
t.Fatal("agent did not complete both lookup and report")
}
}
}
func assertHandoffCredentialUse(t *testing.T, cfg *config.Config, id string, used bool) {
t.Helper()
config.Mu.RLock()
defer config.Mu.RUnlock()
for _, record := range cfg.APITokens {
if record.ID == id {
if (record.LastUsedAt != nil) != used {
t.Fatal("credential last-used evidence does not match authentication")
}
return
}
}
t.Fatal("credential record missing")
}
func persistHandoffToken(t *testing.T, root, state, token string) string {
t.Helper()
script, err := os.ReadFile(filepath.Join(root, "scripts", "install.sh"))
if err != nil {
t.Fatal(err)
}
text := string(script)
start := strings.Index(text, "ensure_runtime_token_file() {")
end := strings.Index(text, "\nclear_proxmox_state_if_needed() {")
if start < 0 || end <= start {
t.Fatal("canonical installer token writer not found")
}
cmd := exec.Command("bash", "-c", `set -euo pipefail
TMP_FILES=(); ENROLL=false; EXIT_GENERAL=1
log_info() { :; }; fail() { exit 1; }
PULSE_TOKEN=$(cat)
`+text[start:end]+`ensure_runtime_token_file "$STATE_DIR"`)
cmd.Env = append(os.Environ(), "STATE_DIR="+state)
cmd.Stdin = strings.NewReader(token)
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("private installer token writer: %v (output bytes %d)", err, len(output))
}
tokenFile := filepath.Join(state, "token")
for path, mode := range map[string]os.FileMode{state: 0700, tokenFile: 0600} {
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != mode {
t.Fatal("installer token writer did not retain private file/parent modes")
}
}
return tokenFile
}
func startHandoffAgent(t *testing.T, binary, url, caFile, tokenFile, state, envToken string) (func(), <-chan error, *bytes.Buffer) {
t.Helper()
cmd := exec.Command(binary, "--url", url, "--cacert", caFile, "--token-file", tokenFile,
"--state-dir", state, "--agent-id", "credential-handoff-agent", "--hostname", "credential-handoff.local",
"--enable-host", "--enable-docker=false", "--enable-kubernetes=false", "--enable-proxmox=false",
"--enable-commands=false", "--command-authority", "monitoring-only", "--disable-auto-update", "--interval", "1s", "--health-addr", "127.0.0.1:0")
for _, value := range os.Environ() {
if !strings.HasPrefix(value, "PULSE_") {
cmd.Env = append(cmd.Env, value)
}
}
cmd.Env = append(cmd.Env, "PULSE_TOKEN="+envToken)
var logs bytes.Buffer
cmd.Stdout, cmd.Stderr = &logs, &logs
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
t.Fatal("start synthetic agent")
}
exit := make(chan error, 1)
done := make(chan struct{})
go func() { exit <- cmd.Wait(); close(done) }()
var once sync.Once
stop := func() {
once.Do(func() {
select {
case <-done:
return
default:
}
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
select {
case <-done:
case <-time.After(10 * time.Second):
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
<-done
t.Error("synthetic agent required forced cleanup")
}
})
}
t.Cleanup(stop)
return stop, exit, &logs
}