From 77afff4f60a14d5cc1d09c8db1a1408d7c5c483c Mon Sep 17 00:00:00 2001 From: Richard Courtman Date: Sun, 30 Aug 2026 18:49:12 +0100 Subject: [PATCH] Bind action runner to enrolled agent identity --- cmd/pulse-agent-runner/main.go | 23 +++++- cmd/pulse-agent-runner/main_test.go | 71 +++++++++++++++++++ .../v6/internal/subsystems/agent-lifecycle.md | 10 ++- .../v6/internal/subsystems/api-contracts.md | 6 +- .../subsystems/deployment-installability.md | 9 ++- .../internal/subsystems/security-privacy.md | 5 +- .../internal/subsystems/storage-recovery.md | 4 ++ internal/actionrunner/runner.go | 2 +- internal/actionrunner/types.go | 12 +++- internal/api/agenttokens/install.go | 7 +- internal/api/agenttokens/install_test.go | 14 ++++ internal/api/security_regression_test.go | 13 ++++ scripts/install.sh | 36 +++++++--- scripts/installtests/install_sh_test.go | 66 ++++++++++++++++- .../canonical_completion_guard_test.py | 1 + 15 files changed, 254 insertions(+), 25 deletions(-) diff --git a/cmd/pulse-agent-runner/main.go b/cmd/pulse-agent-runner/main.go index 69a3a22b67..f00576f2ed 100644 --- a/cmd/pulse-agent-runner/main.go +++ b/cmd/pulse-agent-runner/main.go @@ -26,6 +26,7 @@ type runtimeConfig struct { StateDir string HealthFile string ActivationNonce string + AgentID string AgentIDFile string Hostname string ServerFingerprint string @@ -40,15 +41,19 @@ func loadConfig() (runtimeConfig, error) { StateDir: strings.TrimSpace(os.Getenv("PULSE_AGENT_RUNNER_STATE_DIR")), HealthFile: strings.TrimSpace(os.Getenv("PULSE_AGENT_RUNNER_HEALTH_FILE")), ActivationNonce: strings.TrimSpace(os.Getenv("PULSE_AGENT_RUNNER_ACTIVATION_NONCE")), + AgentID: strings.TrimSpace(os.Getenv("PULSE_AGENT_RUNNER_AGENT_ID")), AgentIDFile: strings.TrimSpace(os.Getenv("PULSE_AGENT_RUNNER_AGENT_ID_FILE")), Hostname: strings.TrimSpace(os.Getenv("PULSE_AGENT_RUNNER_HOSTNAME")), ServerFingerprint: strings.TrimSpace(os.Getenv("PULSE_SERVER_FINGERPRINT")), CAFile: strings.TrimSpace(os.Getenv("SSL_CERT_FILE")), Insecure: strings.EqualFold(strings.TrimSpace(os.Getenv("PULSE_INSECURE")), "true"), } - if config.PulseURL == "" || config.TokenFile == "" || config.StateDir == "" || config.HealthFile == "" || config.AgentIDFile == "" || len(config.ActivationNonce) < 32 || len(config.ActivationNonce) > 128 { + if config.PulseURL == "" || config.TokenFile == "" || config.StateDir == "" || config.HealthFile == "" || (config.AgentID == "" && config.AgentIDFile == "") || len(config.ActivationNonce) < 32 || len(config.ActivationNonce) > 128 { return runtimeConfig{}, errors.New("PULSE_URL and the action-runner token, state, health, agent identity, and activation nonce settings are required") } + if config.AgentID != "" && !actionrunner.IsValidBoundedID(config.AgentID) { + return runtimeConfig{}, errors.New("PULSE_AGENT_RUNNER_AGENT_ID must be a valid agent identity") + } if config.Hostname != "" { hostname, err := normalizeRunnerHostname(config.Hostname) if err != nil { @@ -91,7 +96,7 @@ func run() error { if err != nil { return err } - agentID, err := readPrivateValue(config.AgentIDFile, "runner agent identity") + agentID, err := resolveRunnerAgentID(config) if err != nil { return err } @@ -129,6 +134,20 @@ func run() error { return nil } +func resolveRunnerAgentID(config runtimeConfig) (string, error) { + if config.AgentID != "" { + return config.AgentID, nil + } + agentID, err := readPrivateValue(config.AgentIDFile, "runner agent identity") + if err != nil { + return "", err + } + if !actionrunner.IsValidBoundedID(agentID) { + return "", errors.New("runner agent identity file must contain a valid agent identity") + } + return agentID, nil +} + func normalizeRunnerHostname(value string) (string, error) { hostname := strings.ToLower(strings.TrimSpace(value)) hostname = strings.TrimRight(hostname, ".") diff --git a/cmd/pulse-agent-runner/main_test.go b/cmd/pulse-agent-runner/main_test.go index 7f0df22ae7..e46c6a0cb3 100644 --- a/cmd/pulse-agent-runner/main_test.go +++ b/cmd/pulse-agent-runner/main_test.go @@ -34,6 +34,77 @@ func TestLoadConfigUsesDedicatedEnvironmentAndPrivateTokenFile(t *testing.T) { } } +func TestLoadConfigAcceptsDirectAgentIDWithoutIdentityFile(t *testing.T) { + dir := t.TempDir() + token := filepath.Join(dir, "runner.token") + if err := os.WriteFile(token, []byte("secret\n"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("PULSE_URL", "https://pulse.example") + t.Setenv("PULSE_AGENT_RUNNER_TOKEN_FILE", token) + t.Setenv("PULSE_AGENT_RUNNER_STATE_DIR", filepath.Join(dir, "state")) + t.Setenv("PULSE_AGENT_RUNNER_HEALTH_FILE", filepath.Join(dir, "state", "health.json")) + t.Setenv("PULSE_AGENT_RUNNER_AGENT_ID", "agent-direct-1") + t.Setenv("PULSE_AGENT_RUNNER_AGENT_ID_FILE", "") + t.Setenv("PULSE_AGENT_RUNNER_ACTIVATION_NONCE", strings.Repeat("d", 32)) + config, err := loadConfig() + if err != nil { + t.Fatal(err) + } + if got, err := resolveRunnerAgentID(config); err != nil || got != "agent-direct-1" { + t.Fatalf("resolveRunnerAgentID = %q, %v", got, err) + } +} + +func TestResolveRunnerAgentIDPrefersDirectBoundIdentity(t *testing.T) { + config := runtimeConfig{AgentID: "agent-direct", AgentIDFile: filepath.Join(t.TempDir(), "missing")} + if got, err := resolveRunnerAgentID(config); err != nil || got != "agent-direct" { + t.Fatalf("resolveRunnerAgentID = %q, %v", got, err) + } +} + +func TestResolveRunnerAgentIDFallsBackToPrivateIdentityFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent-id") + if err := os.WriteFile(path, []byte("agent-file\n"), 0600); err != nil { + t.Fatal(err) + } + if got, err := resolveRunnerAgentID(runtimeConfig{AgentIDFile: path}); err != nil || got != "agent-file" { + t.Fatalf("resolveRunnerAgentID = %q, %v", got, err) + } +} + +func TestResolveRunnerAgentIDRejectsInvalidFileIdentity(t *testing.T) { + path := filepath.Join(t.TempDir(), "agent-id") + if err := os.WriteFile(path, []byte("bad agent\n"), 0600); err != nil { + t.Fatal(err) + } + if _, err := resolveRunnerAgentID(runtimeConfig{AgentIDFile: path}); err == nil || !strings.Contains(err.Error(), "valid agent identity") { + t.Fatalf("resolveRunnerAgentID error = %v", err) + } +} + +func TestLoadConfigRejectsInvalidDirectAgentID(t *testing.T) { + dir := t.TempDir() + token := filepath.Join(dir, "runner.token") + if err := os.WriteFile(token, []byte("secret\n"), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("PULSE_URL", "https://pulse.example") + t.Setenv("PULSE_AGENT_RUNNER_TOKEN_FILE", token) + t.Setenv("PULSE_AGENT_RUNNER_STATE_DIR", filepath.Join(dir, "state")) + t.Setenv("PULSE_AGENT_RUNNER_HEALTH_FILE", filepath.Join(dir, "state", "health.json")) + t.Setenv("PULSE_AGENT_RUNNER_AGENT_ID_FILE", "") + t.Setenv("PULSE_AGENT_RUNNER_ACTIVATION_NONCE", strings.Repeat("e", 32)) + for _, agentID := range []string{"bad agent", "-agent", strings.Repeat("a", 129)} { + t.Run(agentID, func(t *testing.T) { + t.Setenv("PULSE_AGENT_RUNNER_AGENT_ID", agentID) + if _, err := loadConfig(); err == nil || !strings.Contains(err.Error(), "valid agent identity") { + t.Fatalf("loadConfig error = %v", err) + } + }) + } +} + func TestLoadConfigRejectsInvalidCanonicalHostnameOverride(t *testing.T) { dir := t.TempDir() token := filepath.Join(dir, "runner.token") diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 6574bb5dfe..00ba98a82a 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -105,7 +105,15 @@ hardened. Qualification must prove an allowed mutation completes as well as proving generic command denial. The runner service persists the same normalized canonical hostname used when its credential was issued; it must not substitute the machine's incidental OS -hostname when the collector was enrolled under an override. Credential +hostname when the collector was enrolled under an override. When enrollment +already supplied the canonical agent ID, the installer also persists that +non-secret ID directly in the root-owned runner environment so runner +registration does not wait for the collector's first-report identity file; +the private identity file remains the compatibility fallback for installs +whose ID is generated later. That direct binding is authoritative for runner +startup, activation health, and self-revocation even if a stale collector +identity file is present. Issuance and every runner boundary use the same +bounded action-identity vocabulary. Credential rotation is a prepare/commit transaction. Issuance stores a ten-minute pending replacement beside the active predecessor; the pending transport may register but is not dispatchable. The runner first durably replaces a private health diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 944ce288c5..831c03585c 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -37,7 +37,11 @@ falls back to a collector or helper asset. `POST monitored `agentId` / `hostname` pair and returns a new, separately persisted `agent:exec` credential bound to organization, agent ID, normalized hostname, runtime role `action-runner`, binding version 1, and capability -`typed_actions.v1`. The route re-resolves the pair against exactly one live, +`typed_actions.v1`. The canonical agent ID must satisfy the same bounded +action-identity vocabulary used by runner configuration, registration, +session admission, typed requests, and receipts; issuance fails before secret +generation when that identity cannot be represented safely at every boundary. +The route re-resolves the pair against exactly one live, non-conflicted, non-integration host in the request tenant. Issuing again atomically prepares a ten-minute replacement beside the current active record; older unactivated replacements are removed, while persistence failure restores diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index 609b8013f8..01353781dc 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -155,7 +155,14 @@ timestamps, never restores a prior marker, and restores the previous runner-only files if the current nonce never reaches activated state. Disable and uninstall remove only remediation and leave monitoring running. The action credential is never placed in argv or reused as the collector token. The -installer persists the canonical enrollment hostname for runner admission and +installer persists the canonical enrollment hostname for runner admission. If +the canonical agent ID is already known, it also writes that non-secret ID to +the root-owned runner environment so the separate runner can register before +the collector's first report populates its identity file; the private identity +file remains the fallback for later-generated IDs. The direct binding takes +precedence consistently during runner startup, activation health, and +self-revocation, and issuance rejects identities outside the runner's bounded +action-identity vocabulary. The installer uses a private curl configuration for best-effort exact self-revocation before local teardown, so the bearer secret does not enter argv. Server unreachability is warned explicitly but does not make local removal depend on the remote diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index f814afe21e..154b15503c 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -93,7 +93,10 @@ Action-runner issuance and rotation are a two-phase durable host-bound transition per organization and canonical agent ID. Re-issuance prepares a ten-minute replacement credential while retaining the prior active record, returns the new plaintext secret only after persistence succeeds, and removes -older unactivated replacements. The pending credential may authenticate one +older unactivated replacements. Issuance, root-owned runtime configuration, +session admission, typed payloads, and receipts share one bounded +action-identity vocabulary, so an unsafe or unrepresentable host identity is +rejected before any action credential is minted. The pending credential may authenticate one exact runner transport but cannot become dispatch authority. After the runner durably records its current activation nonce, its authenticated activation request atomically promotes the replacement and revokes the server-recorded diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 57943561f7..aec8da5340 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -34,6 +34,10 @@ preserves collector identity and installation files only; it must not rewrite storage/recovery evidence or reinterpret a restored legacy collector profile as recovery success. Typed `host.storage_cleanup` remains governed remediation, not storage-recovery authority inferred from the credential itself. +The shared bounded action-identity validator may reject credential issuance +before secret generation when a host ID cannot be represented consistently by +the runner and session protocol. That fail-closed identity result cannot select, +delete, restore, or reclassify storage/recovery state. Runner credential rotation follows the shared two-phase token-inventory commit boundary: issuance durably prepares a bounded, non-dispatchable replacement without removing the active predecessor; activation after durable runner health diff --git a/internal/actionrunner/runner.go b/internal/actionrunner/runner.go index 705287b977..f9a854bb8c 100644 --- a/internal/actionrunner/runner.go +++ b/internal/actionrunner/runner.go @@ -166,7 +166,7 @@ func (r *Runner) Query(session Session, identity operationreceipt.Identity) (ope } func authorize(session Session, request Request) error { - if !boundedID.MatchString(strings.TrimSpace(session.OrganizationID)) || !boundedID.MatchString(strings.TrimSpace(session.HostID)) || !boundedID.MatchString(strings.TrimSpace(session.TokenID)) || !session.Capabilities[ActionCapability] { + if !IsValidBoundedID(session.OrganizationID) || !IsValidBoundedID(session.HostID) || !IsValidBoundedID(session.TokenID) || !session.Capabilities[ActionCapability] { return ErrUnauthorized } if session.OrganizationID != request.OrganizationID || session.HostID != request.HostID { diff --git a/internal/actionrunner/types.go b/internal/actionrunner/types.go index 9ce07a9d10..95df807af5 100644 --- a/internal/actionrunner/types.go +++ b/internal/actionrunner/types.go @@ -28,6 +28,12 @@ const ( var boundedID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`) +// IsValidBoundedID defines the closed identity vocabulary shared by action +// credential issuance, runner configuration, session admission, and receipts. +func IsValidBoundedID(value string) bool { + return boundedID.MatchString(strings.TrimSpace(value)) +} + var ( ErrUnauthorized = errors.New("action runner session is not authorized") ErrUnsupported = errors.New("action runner operation is unsupported") @@ -73,7 +79,7 @@ type Registration struct { func (session Session) Registration() (Registration, error) { organizationID := strings.TrimSpace(session.OrganizationID) hostID := strings.TrimSpace(session.HostID) - if !boundedID.MatchString(organizationID) || !boundedID.MatchString(hostID) || !boundedID.MatchString(strings.TrimSpace(session.TokenID)) || !session.Capabilities[ActionCapability] { + if !IsValidBoundedID(organizationID) || !IsValidBoundedID(hostID) || !IsValidBoundedID(session.TokenID) || !session.Capabilities[ActionCapability] { return Registration{}, ErrUnauthorized } return Registration{RuntimeRole: RuntimeRole, OrganizationID: organizationID, HostID: hostID}, nil @@ -139,7 +145,7 @@ func ValidateRequest(request *Request, now time.Time) error { return fmt.Errorf("unsupported action protocol version %d", request.ProtocolVersion) } for label, value := range map[string]string{"organization_id": request.OrganizationID, "host_id": request.HostID, "attempt_id": request.AttemptID, "action_id": request.ActionID, "target.kind": request.Target.Kind, "target.id": request.Target.ID} { - if !boundedID.MatchString(value) { + if !IsValidBoundedID(value) { return fmt.Errorf("invalid %s", label) } } @@ -220,7 +226,7 @@ func ValidateTerminal(identity operationreceipt.Identity, data json.RawMessage) if result.ProtocolVersion != ProtocolVersion || result.AttemptID != identity.AttemptID || result.ActionID != identity.ActionID || result.Operation != identity.OperationKind || result.OperationVersion != identity.OperationVersion || result.RequestDigest != identity.RequestDigest { return operationreceipt.ErrBindingConflict } - if !boundedID.MatchString(result.Target.Kind) || !boundedID.MatchString(result.Target.ID) { + if !IsValidBoundedID(result.Target.Kind) || !IsValidBoundedID(result.Target.ID) { return fmt.Errorf("invalid action result target") } switch result.Status { diff --git a/internal/api/agenttokens/install.go b/internal/api/agenttokens/install.go index 8bf78e37cc..82295b33cf 100644 --- a/internal/api/agenttokens/install.go +++ b/internal/api/agenttokens/install.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "github.com/rcourtman/pulse-go-rewrite/internal/actionrunner" "github.com/rcourtman/pulse-go-rewrite/internal/config" "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources" internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth" @@ -225,13 +226,13 @@ func IssueActionRunnerAndPersistDetailed(cfg *config.Config, persistence *config if organizationID == "" { return ActionRunnerIssueResult{}, fmt.Errorf("%w: organization id is required", ErrRecord) } - if agentID == "" { - return ActionRunnerIssueResult{}, fmt.Errorf("%w: canonical agent id is required", ErrRecord) + if !actionrunner.IsValidBoundedID(agentID) { + return ActionRunnerIssueResult{}, fmt.Errorf("%w: canonical agent id is invalid", ErrRecord) } if hostname == "" { return ActionRunnerIssueResult{}, fmt.Errorf("%w: canonical hostname is required", ErrRecord) } - if len(agentID) > 128 || len(hostname) > 253 { + if len(hostname) > 253 { return ActionRunnerIssueResult{}, fmt.Errorf("%w: action runner identity exceeds maximum length", ErrRecord) } diff --git a/internal/api/agenttokens/install_test.go b/internal/api/agenttokens/install_test.go index 9c2279f7b1..d7ffadc70f 100644 --- a/internal/api/agenttokens/install_test.go +++ b/internal/api/agenttokens/install_test.go @@ -4,6 +4,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "time" @@ -74,6 +75,19 @@ func TestIssueActionRunnerAndPersistRejectsIncompleteBinding(t *testing.T) { } } +func TestIssueActionRunnerAndPersistRejectsAgentIDOutsideRunnerVocabulary(t *testing.T) { + for _, agentID := range []string{"bad agent", "-agent", strings.Repeat("a", 129)} { + t.Run(agentID, func(t *testing.T) { + raw, record, err := IssueActionRunnerAndPersist(&config.Config{}, nil, ActionRunnerIssueOptions{ + OrgID: "org-a", AgentID: agentID, Hostname: "node.example", + }) + if !errors.Is(err, ErrRecord) || raw != "" || record != nil { + t.Fatalf("IssueActionRunnerAndPersist = (%q, %#v, %v), want ErrRecord", raw, record, err) + } + }) + } +} + func TestIssueActionRunnerAndPersistPreparesRotationWithoutRevokingActiveCredential(t *testing.T) { cfg := &config.Config{DataPath: t.TempDir()} firstToken, first, err := IssueActionRunnerAndPersist(cfg, nil, ActionRunnerIssueOptions{ diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 7620104ed7..0525ad445c 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -687,6 +687,19 @@ func TestActionRunnerCredentialAdmissionBindsTenantHostRoleAndCapability(t *test } } +func TestActionRunnerCredentialIssuanceRejectsIdentityOutsideSessionVocabulary(t *testing.T) { + for _, agentID := range []string{"bad agent", "-machine", strings.Repeat("a", 129)} { + t.Run(agentID, func(t *testing.T) { + rawToken, record, err := agenttokens.IssueActionRunnerAndPersist(&config.Config{}, nil, agenttokens.ActionRunnerIssueOptions{ + OrgID: "org-a", AgentID: agentID, Hostname: "node.example", + }) + if !errors.Is(err, agenttokens.ErrRecord) || rawToken != "" || record != nil { + t.Fatalf("action-runner issuance = (%q, %#v, %v), want closed identity rejection", rawToken, record, err) + } + }) + } +} + func TestActionRunnerCredentialAdmissionRejectsWrongIdentityAndUnboundOrganization(t *testing.T) { for _, tc := range []struct { name string diff --git a/scripts/install.sh b/scripts/install.sh index 21e13dcbe2..21cedbd751 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -963,6 +963,7 @@ read_action_runner_env_value() { revoke_action_runner_credential() { local runner_url="" local runner_hostname="" + local runner_agent_id_direct="" local runner_agent_id_file="" local runner_agent_id="" local runner_token_file="" @@ -975,17 +976,22 @@ revoke_action_runner_credential() { runner_url=$(read_action_runner_env_value "PULSE_URL" || true) runner_hostname=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" || true) + runner_agent_id_direct=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" || true) runner_agent_id_file=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" || true) runner_token_file=$(read_action_runner_env_value "PULSE_AGENT_RUNNER_TOKEN_FILE" || true) [[ "$runner_url" =~ ^https?://[^[:space:]]+$ && -n "$runner_hostname" ]] || return 1 - [[ "$runner_agent_id_file" == /* && "$runner_agent_id_file" != *'/../'* && - -f "$runner_agent_id_file" && ! -L "$runner_agent_id_file" ]] || return 1 [[ "$runner_token_file" == /* && "$runner_token_file" != *'/../'* && -f "$runner_token_file" && ! -L "$runner_token_file" ]] || return 1 - runner_agent_id=$(head -1 "$runner_agent_id_file" 2>/dev/null || true) - (( ${#runner_agent_id} >= 1 && ${#runner_agent_id} <= 256 && + if [[ -n "$runner_agent_id_direct" ]]; then + runner_agent_id="$runner_agent_id_direct" + else + [[ "$runner_agent_id_file" == /* && "$runner_agent_id_file" != *'/../'* && + -f "$runner_agent_id_file" && ! -L "$runner_agent_id_file" ]] || return 1 + runner_agent_id=$(head -1 "$runner_agent_id_file" 2>/dev/null || true) + fi + (( ${#runner_agent_id} >= 1 && ${#runner_agent_id} <= 128 && ${#runner_hostname} >= 1 && ${#runner_hostname} <= 253 )) || return 1 - [[ "$runner_agent_id" =~ ^[A-Za-z0-9._:-]+$ && + [[ "$runner_agent_id" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ && "$runner_hostname" =~ ^[A-Za-z0-9._:-]+$ ]] || return 1 token_mode=$(stat -c '%a' "$runner_token_file" 2>/dev/null || true) token_size=$(wc -c < "$runner_token_file" 2>/dev/null | tr -d ' ' || true) @@ -1368,6 +1374,11 @@ write_action_runner_config() { write_action_runner_env_value "PULSE_AGENT_RUNNER_STATE_DIR" "$ACTION_RUNNER_STATE_DIR" write_action_runner_env_value "PULSE_AGENT_RUNNER_HEALTH_FILE" "$ACTION_RUNNER_HEALTH_FILE" write_action_runner_env_value "PULSE_AGENT_RUNNER_ACTIVATION_NONCE" "$ACTION_RUNNER_ACTIVATION_NONCE" + if [[ -n "$AGENT_ID" ]]; then + [[ ${#AGENT_ID} -le 128 && "$AGENT_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]] || + fail "Action runner requires a valid canonical agent identity" "$EXIT_MISSING_ARGS" + write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" "$AGENT_ID" + fi write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" "${STATE_DIR%/}/agent-id" write_action_runner_env_value "PULSE_AGENT_RUNNER_HOSTNAME" "$runner_hostname" if [[ -n "$SERVER_FINGERPRINT" ]]; then @@ -1420,6 +1431,15 @@ write_action_runner_env_value() { printf '%s="%s"\n' "$key" "$value" >> "$ACTION_RUNNER_ENV_FILE" } +resolve_action_runner_agent_id() { + local agent_id="${AGENT_ID:-}" + if [[ -z "$agent_id" && -s "${STATE_DIR%/}/agent-id" && ! -L "${STATE_DIR%/}/agent-id" ]]; then + agent_id=$(head -1 "${STATE_DIR%/}/agent-id" 2>/dev/null || true) + fi + [[ ${#agent_id} -ge 1 && ${#agent_id} -le 128 && "$agent_id" =~ ^[A-Za-z0-9][A-Za-z0-9._:-]*$ ]] || return 1 + printf '%s\n' "$agent_id" +} + provision_action_runner() { local backup_suffix=".pulse-install-backup.$$" local path="" @@ -1473,10 +1493,8 @@ provision_action_runner() { if [[ "$apply_succeeded" == "true" ]]; then local attempt=0 while [[ "$attempt" -lt 30 ]]; do - local expected_agent_id="${AGENT_ID}" - if [[ -s "${STATE_DIR%/}/agent-id" ]]; then - expected_agent_id=$(head -1 "${STATE_DIR%/}/agent-id" 2>/dev/null || true) - fi + local expected_agent_id="" + expected_agent_id=$(resolve_action_runner_agent_id || true) if systemctl is-active --quiet "${ACTION_RUNNER_NAME}.service" && action_runner_health_matches_activation "$expected_agent_id" "$activation_nonce"; then runner_active="true" diff --git a/scripts/installtests/install_sh_test.go b/scripts/installtests/install_sh_test.go index 52830cbc08..eb5d54d763 100644 --- a/scripts/installtests/install_sh_test.go +++ b/scripts/installtests/install_sh_test.go @@ -5952,6 +5952,7 @@ func TestInstallSHActionRunnerIsSeparateOptInLifecycle(t *testing.T) { `The action runner must use a separate credential from the collector token`, `Preserving existing separately enabled action-runner profile`, `write_action_runner_env_value "PULSE_AGENT_RUNNER_TOKEN_FILE" "$ACTION_RUNNER_TOKEN_FILE"`, + `write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID" "$AGENT_ID"`, `write_action_runner_env_value "PULSE_AGENT_RUNNER_AGENT_ID_FILE" "${STATE_DIR%/}/agent-id"`, `write_action_runner_env_value "PULSE_AGENT_RUNNER_HEALTH_FILE" "$ACTION_RUNNER_HEALTH_FILE"`, `write_action_runner_env_value "PULSE_AGENT_RUNNER_ACTIVATION_NONCE" "$ACTION_RUNNER_ACTIVATION_NONCE"`, @@ -5979,6 +5980,66 @@ func TestInstallSHActionRunnerIsSeparateOptInLifecycle(t *testing.T) { } } +func TestInstallSHActionRunnerConfigBindsKnownAgentIDImmediately(t *testing.T) { + root := t.TempDir() + envFile := filepath.Join(root, "runner.env") + script := ` +set -euo pipefail +ACTION_RUNNER_CONFIG_DIR="` + filepath.Join(root, "config") + `" +ACTION_RUNNER_STATE_DIR="` + filepath.Join(root, "runner-state") + `" +ACTION_RUNNER_TOKEN_FILE="` + filepath.Join(root, "config", "token") + `" +ACTION_RUNNER_ENV_FILE="` + envFile + `" +ACTION_RUNNER_HEALTH_FILE="` + filepath.Join(root, "runner-state", "health.json") + `" +ACTION_RUNNER_ACTIVATION_NONCE="` + strings.Repeat("a", 64) + `" +STATE_DIR="` + filepath.Join(root, "collector-state") + `" +HOSTNAME_OVERRIDE="secure-runtime.lab" +AGENT_ID="agent-bound-before-collector-report" +ACTION_TOKEN="runner-secret" +PULSE_URL="https://pulse.example" +SERVER_FINGERPRINT="" +CURL_CA_BUNDLE="" +INSECURE="false" +EXIT_GENERAL=1 +EXIT_MISSING_ARGS=2 +fail() { printf '%s\n' "$1" >&2; exit "$2"; } +install() { local destination="${!#}"; mkdir -p "$destination"; chmod 0700 "$destination"; } +chown() { return 0; } +` + extractInstallShellFunction(t, "write_action_runner_env_value") + ` +` + extractInstallShellFunction(t, "write_action_runner_config") + ` +write_action_runner_config +` + if out, err := exec.Command("bash", "-c", script).CombinedOutput(); err != nil { + t.Fatalf("write action-runner config: %v\n%s", err, out) + } + content, err := os.ReadFile(envFile) + if err != nil { + t.Fatal(err) + } + want := `PULSE_AGENT_RUNNER_AGENT_ID="agent-bound-before-collector-report"` + if !strings.Contains(string(content), want) { + t.Fatalf("action-runner environment missing immediate identity binding %q:\n%s", want, content) + } +} + +func TestInstallSHActionRunnerDirectIdentityOverridesStaleCollectorFile(t *testing.T) { + stateDir := t.TempDir() + mustWrite(t, filepath.Join(stateDir, "agent-id"), "stale-agent-id\n") + script := ` +set -euo pipefail +STATE_DIR="` + stateDir + `" +AGENT_ID="current-agent-id" +` + extractInstallShellFunction(t, "resolve_action_runner_agent_id") + ` +resolve_action_runner_agent_id +` + out, err := exec.Command("bash", "-c", script).CombinedOutput() + if err != nil { + t.Fatalf("resolve action-runner identity: %v\n%s", err, out) + } + if got := strings.TrimSpace(string(out)); got != "current-agent-id" { + t.Fatalf("resolved action-runner identity = %q, want direct binding", got) + } +} + func TestInstallSHActionRunnerReadinessRequiresCurrentActivationNonce(t *testing.T) { root := t.TempDir() healthPath := filepath.Join(root, "health.json") @@ -6055,16 +6116,15 @@ func TestInstallSHActionRunnerSelfRevokeUsesPrivateCredential(t *testing.T) { root := t.TempDir() envFile := filepath.Join(root, "runner.env") tokenFile := filepath.Join(root, "token") - agentIDFile := filepath.Join(root, "agent-id") mustWrite(t, tokenFile, token+"\n") - mustWrite(t, agentIDFile, "agent-secure-runtime\n") if err := os.Chmod(tokenFile, 0o600); err != nil { t.Fatal(err) } mustWrite(t, envFile, strings.Join([]string{ `PULSE_URL="` + server.URL + `"`, `PULSE_AGENT_RUNNER_HOSTNAME="secure-runtime.lab"`, - `PULSE_AGENT_RUNNER_AGENT_ID_FILE="` + agentIDFile + `"`, + `PULSE_AGENT_RUNNER_AGENT_ID="agent-secure-runtime"`, + `PULSE_AGENT_RUNNER_AGENT_ID_FILE="` + filepath.Join(root, "missing-agent-id") + `"`, `PULSE_AGENT_RUNNER_TOKEN_FILE="` + tokenFile + `"`, }, "\n")+"\n") diff --git a/scripts/release_control/canonical_completion_guard_test.py b/scripts/release_control/canonical_completion_guard_test.py index fe36cd7e6f..31a3022a2f 100644 --- a/scripts/release_control/canonical_completion_guard_test.py +++ b/scripts/release_control/canonical_completion_guard_test.py @@ -255,6 +255,7 @@ class CanonicalCompletionGuardTest(unittest.TestCase): self.assertEqual( policy_ids, [ + "service-discovery-assurance-runtime", "maintenance-verification-runtime", "availability-certificate-runtime", "discovery-provider-runtime",