Repository: kolide/launcher

Issue:
Control server agent flags of type `time.Duration` not stored or parsed as expected
When we receive a time like `4s` or `10m`, the control service stores this as a raw string. But when we pull it out of the data store, the control service expects it to be in nanoseconds. We should update how we store it or how we parse it from storage.

Fix must be backwards-compatible with current control server workaround of sending down the duration in nanoseconds instead of using a duration.

Discussion:

Solution patch:
diff --git a/ee/agent/flags/flag_value_duration.go b/ee/agent/flags/flag_value_duration.go
index 13860162..acd5a72b 100644
--- a/ee/agent/flags/flag_value_duration.go
+++ b/ee/agent/flags/flag_value_duration.go
@@ -62,19 +62,9 @@ func NewDurationFlagValue(slogger *slog.Logger, key keys.FlagKey, opts ...durati
 
 func (d *durationFlagValue) get(controlServerValue []byte) time.Duration {
 	int64Value := d.defaultVal
+
 	if controlServerValue != nil {
-		// Control server provided integers are stored as strings and need to be converted back
-		var err error
-		parsedInt, err := strconv.ParseInt(string(controlServerValue), 10, 64)
-		if err == nil {
-			int64Value = parsedInt
-		} else {
-			d.slogger.Log(context.TODO(), slog.LevelDebug,
-				"failed to convert stored duration flag value",
-				"key", d.key,
-				"err", err,
-			)
-		}
+		int64Value = d.parseControlServerValue(controlServerValue)
 	}
 
 	if d.override != nil && d.override.Value() != nil {
@@ -90,6 +80,29 @@ func (d *durationFlagValue) get(controlServerValue []byte) time.Duration {
 	return time.Duration(int64Value)
 }
 
+// parseControlServerValue attempts to parse the control server value as either a duration string or nanoseconds
+func (d *durationFlagValue) parseControlServerValue(controlServerValue []byte) int64 {
+	valueStr := string(controlServerValue)
+
+	// First try to parse as a duration string (e.g., "4s", "10m")
+	if parsedDuration, err := time.ParseDuration(valueStr); err == nil {
+		return int64(parsedDuration)
+	}
+
+	// Fall back to parsing as nanoseconds for backward compatibility
+	if parsedInt, err := strconv.ParseInt(valueStr, 10, 64); err == nil {
+		return parsedInt
+	}
+
+	// Both parsing attempts failed, log and return default
+	d.slogger.Log(context.TODO(), slog.LevelDebug,
+		"failed to convert stored duration flag value",
+		"key", d.key,
+		"value", valueStr,
+	)
+	return d.defaultVal
+}
+
 // clampValue returns a value that is clamped to be within the range defined by min and max.
 func clampValue(value int64, minimum, maximum int64) int64 {
 	switch {



Test patch:
diff --git a/ee/agent/flags/flag_value_duration_test.go b/ee/agent/flags/flag_value_duration_test.go
index f7ad7624..8dbd672c 100644
--- a/ee/agent/flags/flag_value_duration_test.go
+++ b/ee/agent/flags/flag_value_duration_test.go
@@ -67,6 +67,45 @@ func TestFlagValueDuration(t *testing.T) {
 			controlServerValue: []byte("30000000000"),
 			expectedValue:      25 * time.Second,
 		},
+		// New tests for duration string support
+		{
+			name:               "duration string seconds",
+			controlServerValue: []byte("4s"),
+			expectedValue:      4 * time.Second,
+		},
+		{
+			name:               "duration string minutes",
+			controlServerValue: []byte("10m"),
+			expectedValue:      10 * time.Minute,
+		},
+		{
+			name:               "duration string hours",
+			controlServerValue: []byte("2h"),
+			expectedValue:      2 * time.Hour,
+		},
+		{
+			name:               "duration string complex",
+			controlServerValue: []byte("1h30m45s"),
+			expectedValue:      1*time.Hour + 30*time.Minute + 45*time.Second,
+		},
+		{
+			name:               "duration string with default fallback",
+			options:            []durationOption{WithDefault(5 * time.Second)},
+			controlServerValue: []byte("30s"),
+			expectedValue:      30 * time.Second,
+		},
+		{
+			name:               "legacy nanoseconds still work",
+			options:            []durationOption{WithDefault(5 * time.Second)},
+			controlServerValue: []byte("300000000000"), // 5 minutes in nanoseconds
+			expectedValue:      5 * time.Minute,
+		},
+		{
+			name:               "invalid duration string falls back to default",
+			options:            []durationOption{WithDefault(5 * time.Second)},
+			controlServerValue: []byte("invalid-duration"),
+			expectedValue:      5 * time.Second,
+		},
 	}
 	for _, tt := range tests {
 		tt := tt