diff --git a/cmd/pulse-agent/main.go b/cmd/pulse-agent/main.go index 1822a73141..20a7f5dc98 100644 --- a/cmd/pulse-agent/main.go +++ b/cmd/pulse-agent/main.go @@ -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 diff --git a/cmd/pulse-agent/main_test.go b/cmd/pulse-agent/main_test.go index 080c11dd00..72ae657315 100644 --- a/cmd/pulse-agent/main_test.go +++ b/cmd/pulse-agent/main_test.go @@ -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{}) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index bf0471ad33..9d2b274f1c 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -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` diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index d58162dc6c..cacd0df431 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -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 diff --git a/internal/api/host_agent_credential_handoff_test.go b/internal/api/host_agent_credential_handoff_test.go new file mode 100644 index 0000000000..85c91341fe --- /dev/null +++ b/internal/api/host_agent_credential_handoff_test.go @@ -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 +}