Repository: kolide/launcher

Issue:
`launcher.exe version` should not return exit code 1 when bootstrapping an autoupdate
I noticed that the Windows end-to-end tests were failing:

```
Test Update: FAILED: launcher did not update within 6m0s: still on 1.6.7-7-g8e5c472: timeout after 6m0s (7 attempts): checking launcher version: running launcher --version: exit status 1; output `launcher - version 1.7.0-1-ge13781c
  branch: 	main
  revision: 	e13781cadcb35808ca4a3a6a612133df06970c67
  build date: 	2024-06-10
  build user: 	 (fv-az1111-111\runneradmin)
  go version: 	go1.21.11
```

Running locally, I see this additional log:

```
{"time":"2024-06-10T18:40:31.0761251Z","level":"ERROR","msg":"error execing newer version of launcher","launcher_run_id":"01J01Q7B7K8FNJN4M6479G9900","new_binary":"C:\\ProgramData\\Kolide\\Launcher-kolide-k2\\data\\updates\\launcher\\1.7.1\\launcher.exe","err":"exec completed with exit code 0"}
```

Whenever launcher has an autoupdate available that the execwrapper attempts to run for `launcher.exe version`, we will run into this bug, where we return an error when we should not.

Since this does not affect autoupdate, due to [this error code suppression function](https://github.com/kolide/launcher/blob/main/ee/tuf/util.go#L71), this is not critical to fix for this release -- but it should get fixed so the end-to-end automated tests will run again.

I think the source of this bug is likely the changes in https://github.com/kolide/launcher/pull/1693.

Discussion:

Solution patch:
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 097f6ca5..6f8e6d0b 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -21,7 +21,7 @@ jobs:
       fail-fast: false # Consider changing this sometime
       matrix:
         os:
-          - macos-13
+          - macos-14
           - windows-latest
     steps:
     - name: Check out code
@@ -200,7 +200,7 @@ jobs:
       matrix:
         os:
           - ubuntu-22.04
-          - macos-13
+          - macos-14
           - windows-latest
     steps:
     - name: Check out code
@@ -263,7 +263,6 @@ jobs:
           # See https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners#supported-runners-and-hardware-resources
           - ubuntu-22.04
           - ubuntu-24.04
-          - macos-13
           - macos-14
           - macos-15
           - windows-2022
@@ -419,7 +418,7 @@ jobs:
       matrix:
         os:
           - ubuntu-22.04
-          - macos-13
+          - macos-14
           - windows-latest
     steps:
     - uses: actions/checkout@v4
diff --git a/cmd/launcher/main.go b/cmd/launcher/main.go
index 76cc70ba..ca47410a 100644
--- a/cmd/launcher/main.go
+++ b/cmd/launcher/main.go
@@ -88,13 +88,35 @@ func runMain() int {
 	// fork-bombing itself. This is an ENV, because there's no
 	// good way to pass it through the flags.
 	if !env.Bool("LAUNCHER_SKIP_UPDATES", false) && !inBuildDir {
-		if err := runNewerLauncherIfAvailable(ctx, systemSlogger.Logger); err != nil {
-			systemSlogger.Log(ctx, slog.LevelInfo,
-				"could not run newer version of launcher",
+		// not using the runNewerLauncherIfAvailable helper here because we need to distinguish between
+		// commands that are expected to exit (like subcommands) and those that are not (like the main launcher run / svc* commands)
+		lastestLauncherPath, err := latestLauncherPath(ctx, systemSlogger.Logger)
+		if err != nil {
+			systemSlogger.Log(ctx, slog.LevelError,
+				"could not check out latest launcher",
 				"err", err,
 			)
 			return 1
 		}
+
+		if lastestLauncherPath != "" {
+			systemSlogger.Log(ctx, slog.LevelInfo,
+				"found newer version of launcher to run",
+				"new_binary", lastestLauncherPath,
+			)
+
+			if err := execwrapper.Exec(ctx, systemSlogger.Logger, lastestLauncherPath, os.Args, os.Environ(), commandExpectedToExit()); err != nil {
+				systemSlogger.Log(ctx, slog.LevelError,
+					"error execing newer version of launcher",
+					"new_binary", lastestLauncherPath,
+					"err", err,
+				)
+
+				return 1
+			}
+
+			return 0
+		}
 	}
 
 	// if the launcher is being ran with a positional argument,
@@ -187,7 +209,8 @@ func runMain() int {
 			level.Debug(logger).Log("msg", "could not get current executable to perform restart", "err", err.Error())
 			return 1
 		}
-		if err := execwrapper.Exec(ctx, systemSlogger.Logger, currentExecutable, os.Args, os.Environ()); err != nil {
+
+		if err := execwrapper.Exec(ctx, systemSlogger.Logger, currentExecutable, os.Args, os.Environ(), false); err != nil {
 			slogger.Log(ctx, slog.LevelError,
 				"error execing launcher after restart was requested",
 				"binary", currentExecutable,
@@ -266,7 +289,7 @@ func runNewerLauncherIfAvailable(ctx context.Context, slogger *slog.Logger) erro
 		"new_binary", newerBinary,
 	)
 
-	if err := execwrapper.Exec(ctx, slogger, newerBinary, os.Args, os.Environ()); err != nil {
+	if err := execwrapper.Exec(ctx, slogger, newerBinary, os.Args, os.Environ(), commandExpectedToExit()); err != nil {
 		slogger.Log(ctx, slog.LevelError,
 			"error execing newer version of launcher",
 			"new_binary", newerBinary,
@@ -327,3 +350,10 @@ func runVersion(_ *multislogger.MultiSlogger, args []string) error {
 
 	return nil
 }
+
+// commandExpectedToExit determines if we're running a subcommand that is expected to exit (excluding those starting with "svc")
+// svc is used on windows to run as a service and we need a non-zero exit code for those no matter
+// the reason for the exit so the service manager will auto restart launcher
+func commandExpectedToExit() bool {
+	return len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") && !strings.HasPrefix(os.Args[1], "svc")
+}
diff --git a/pkg/execwrapper/exec.go b/pkg/execwrapper/exec.go
index 7651db00..3516e9e4 100644
--- a/pkg/execwrapper/exec.go
+++ b/pkg/execwrapper/exec.go
@@ -9,6 +9,6 @@ import (
 	"syscall"
 )
 
-func Exec(ctx context.Context, _ *slog.Logger, argv0 string, argv []string, envv []string) (err error) {
+func Exec(_ context.Context, _ *slog.Logger, argv0 string, argv []string, envv []string, _ bool) (err error) {
 	return syscall.Exec(argv0, argv, envv)
 }
diff --git a/pkg/execwrapper/exec_windows.go b/pkg/execwrapper/exec_windows.go
index d18a60c8..c6db93f2 100644
--- a/pkg/execwrapper/exec_windows.go
+++ b/pkg/execwrapper/exec_windows.go
@@ -16,7 +16,7 @@ import (
 	"github.com/kolide/launcher/ee/gowrapper"
 )
 
-func Exec(ctx context.Context, slogger *slog.Logger, argv0 string, argv []string, envv []string) error {
+func Exec(ctx context.Context, slogger *slog.Logger, argv0 string, argv []string, envv []string, commandExpectedToExit bool) error {
 	cmd := exec.CommandContext(ctx, argv0, argv[1:]...) //nolint:forbidigo // execwrapper is used exclusively to exec launcher, and we trust the autoupdate library to find the correct path.
 	cmd.Env = envv
 
@@ -54,6 +54,14 @@ func Exec(ctx context.Context, slogger *slog.Logger, argv0 string, argv []string
 		return fmt.Errorf("execing %s returned exit code -1 and state %s: %w", fullCmd, cmd.ProcessState.String(), err)
 	}
 
+	// if we're running a subcommand such as version and we did not get an error, return nil
+	// the svc* subcommands are used on windows to run as a service and we need a non-zero exit code for those despite
+	// the reason for the exit so the service manager will auto restart launcher
+	if commandExpectedToExit {
+		return nil
+	}
+
+	// not a sub command, this is launcher running it's main command or svc* subcommand
 	return fmt.Errorf("exec completed with exit code %d", cmd.ProcessState.ExitCode())
 }
 
diff --git a/pkg/osquery/extension.go b/pkg/osquery/extension.go
index 8d452b89..90456867 100644
--- a/pkg/osquery/extension.go
+++ b/pkg/osquery/extension.go
@@ -473,9 +473,12 @@ func (e *Extension) Enroll(ctx context.Context) (string, bool, error) {
 	)
 	span.AddEvent("starting_enrollment")
 
+	// We cannot perform reenrollment via this extension for secretless installations,
+	// because reenrollment requires an enroll secret. Instead, launcher will have to reenroll
+	// via localserver.
 	enrollSecret, err := e.knapsack.ReadEnrollSecret()
 	if err != nil {
-		return "", true, fmt.Errorf("could not read enroll secret: %w", err)
+		return "", true, fmt.Errorf("could not read enroll secret, assuming launcher secretless installation, cannot enroll: %w", err)
 	}
 
 	identifier, err := e.getHostIdentifier()
@@ -563,15 +566,53 @@ func (e *Extension) enrolled() bool {
 	return nodeKey != ""
 }
 
+// nodeKey returns the current node key for this registration. It ensures that if the
+// node key is set in the database but not in the extension, we will pull it. We should therefore
+// always use this function when we need the node key to make requests to the device server,
+// to avoid making requests with an empty node key and attempting to reenroll instead.
+func (e *Extension) nodeKey() string {
+	e.enrollMutex.Lock()
+	defer e.enrollMutex.Unlock()
+
+	nodeKey := e.NodeKey
+	if nodeKey != "" {
+		return nodeKey
+	}
+
+	// Not yet set on the extension, but may be available from the database
+	nodeKey, _ = NodeKey(e.knapsack.ConfigStore(), e.registrationId)
+	if nodeKey != "" {
+		// Set it on the extension so we don't have to fetch it from the database in the future
+		e.NodeKey = nodeKey
+	}
+
+	return nodeKey
+}
+
 // RequireReenroll clears the existing node key information, ensuring that the
 // next call to Enroll will cause the enrollment process to take place.
 func (e *Extension) RequireReenroll(ctx context.Context) {
+	// We cannot perform reenrollment via this extension for secretless installations,
+	// because reenrollment requires an enroll secret. Instead, launcher will have to reenroll
+	// via localserver.
+	enrollSecret, err := e.knapsack.ReadEnrollSecret()
+	isSecretless := enrollSecret == "" || err != nil
+	if isSecretless {
+		e.slogger.Log(ctx, slog.LevelWarn,
+			"RequireReenroll called for secretless installation, will not reenroll",
+		)
+		return
+	}
 	e.enrollMutex.Lock()
 	defer e.enrollMutex.Unlock()
 	// Clear the node key such that reenrollment is required.
 	e.NodeKey = ""
 	e.knapsack.ConfigStore().Delete(storage.KeyByIdentifier([]byte(nodeKeyKey), storage.IdentifierTypeRegistration, []byte(e.registrationId)))
 	e.knapsack.RegistrationStore().Delete([]byte(e.registrationId))
+
+	e.slogger.Log(ctx, slog.LevelWarn,
+		"required reeenroll by removing node key and registration",
+	)
 }
 
 // GenerateConfigs will request the osquery configuration from the server. If
@@ -622,9 +663,7 @@ var reenrollmentInvalidErr = errors.New("enrollment invalid, reenrollment invali
 // Helper to allow for a single attempt at re-enrollment
 func (e *Extension) generateConfigsWithReenroll(ctx context.Context, reenroll bool) (string, error) {
 	// grab a reference to the existing nodekey to prevent data races with any re-enrollments
-	e.enrollMutex.Lock()
-	nodeKey := e.NodeKey
-	e.enrollMutex.Unlock()
+	nodeKey := e.nodeKey()
 
 	config, invalid, err := e.serviceClient.RequestConfig(ctx, nodeKey)
 	switch {
@@ -895,9 +934,7 @@ func (e *Extension) writeBufferedLogsForType(typ logger.LogType) error {
 // Helper to allow for a single attempt at re-enrollment
 func (e *Extension) writeLogsWithReenroll(ctx context.Context, typ logger.LogType, logs []string, reenroll bool) error {
 	// grab a reference to the existing nodekey to prevent data races with any re-enrollments
-	e.enrollMutex.Lock()
-	nodeKey := e.NodeKey
-	e.enrollMutex.Unlock()
+	nodeKey := e.nodeKey()
 
 	_, _, invalid, err := e.serviceClient.PublishLogs(ctx, nodeKey, typ, logs)
 
@@ -1044,9 +1081,7 @@ func (e *Extension) getQueriesWithReenroll(ctx context.Context, reenroll bool) (
 	defer span.End()
 
 	// grab a reference to the existing nodekey to prevent data races with any re-enrollments
-	e.enrollMutex.Lock()
-	nodeKey := e.NodeKey
-	e.enrollMutex.Unlock()
+	nodeKey := e.nodeKey()
 
 	// Note that we set invalid two ways -- in the return, and via isNodeinvaliderr
 	queries, invalid, err := e.serviceClient.RequestQueries(ctx, nodeKey)
@@ -1111,9 +1146,7 @@ func (e *Extension) writeResultsWithReenroll(ctx context.Context, results []dist
 	defer span.End()
 
 	// grab a reference to the existing nodekey to prevent data races with any re-enrollments
-	e.enrollMutex.Lock()
-	nodeKey := e.NodeKey
-	e.enrollMutex.Unlock()
+	nodeKey := e.nodeKey()
 
 	_, _, invalid, err := e.serviceClient.PublishResults(ctx, nodeKey, results)
 	switch {



Test patch:
diff --git a/pkg/execwrapper/exec_test.go b/pkg/execwrapper/exec_test.go
index c973b025..e1e2df95 100644
--- a/pkg/execwrapper/exec_test.go
+++ b/pkg/execwrapper/exec_test.go
@@ -18,13 +18,57 @@ func TestExec(t *testing.T) {
 		Level: slog.LevelDebug,
 	}))
 
-	// Exec expects the process to continue running (because it expects to be running launcher),
-	// so any exit will be an error. Therefore, we expect an error here.
-	err := Exec(t.Context(), slogger, "echo", []string{"test string"}, os.Environ())
+	// by not setting command and args on non-windows, we will trigger an error in syscall.Exec
+	// otherwise we'll never return from Exec on non-windows because syscall.Exec replaces the current process
+	command := ""
+	args := []string{}
+
+	// on windows we want to actually run something that will exit cleanly
+	if runtime.GOOS == "windows" {
+		command = "cmd.exe"
+		// Exec removes the first argument, so add a blank to make sure our args get processed
+		args = []string{"", "/c", "echo", "test string"}
+	}
+
+	// non-windows will give us an error here because syscall.Exec will fail
+	// echo on windows will exit cleanly, but we expect an error because we set commandExpectedToExit to false
+	err := Exec(t.Context(), slogger, command, args, os.Environ(), false)
 	require.Error(t, err)
 
-	// We should expect at least SOMETHING to be logged on Windows
 	if runtime.GOOS == "windows" {
-		require.Greater(t, len(logBytes.String()), 0)
+		// your eyes do not decive you, we expect an exit status 0 in the logs even though Exec returned an error
+		// this is because Exec is used by the main launcher process to run new versions of launcher, if the newer version
+		// exits for ANY reason we want that treated as an error so the main process will exit with an error so that
+		// windows service manager knows to restart it
+		require.Contains(t, logBytes.String(), "exit status 0")
+	}
+}
+
+func TestExecSubcommand(t *testing.T) {
+	t.Parallel()
+
+	if runtime.GOOS != "windows" {
+		t.Skip("skiping on non-windows, because syscall.Exec replaces the current process and the test never completes")
+	}
+
+	var logBytes threadsafebuffer.ThreadSafeBuffer
+	slogger := slog.New(slog.NewTextHandler(&logBytes, &slog.HandlerOptions{
+		Level: slog.LevelDebug,
+	}))
+
+	command := "/bin/echo"
+	args := []string{"echo", "test string"}
+
+	if runtime.GOOS == "windows" {
+		command = "cmd.exe"
+		// Exec removes the first argument, so add a blank to make sure our args get processed
+		args = []string{"", "/c", "echo", "test string"}
 	}
+
+	// flag this call as a non-svc subcommand so if it exist with out error, we do not return an error
+	err := Exec(t.Context(), slogger, command, args, os.Environ(), true)
+	require.NoError(t, err)
+
+	// We should expect at least SOMETHING to be logged on Windows
+	require.Contains(t, logBytes.String(), "exit status 0")
 }
diff --git a/pkg/osquery/extension_test.go b/pkg/osquery/extension_test.go
index d0bbf686..47b10f3c 100644
--- a/pkg/osquery/extension_test.go
+++ b/pkg/osquery/extension_test.go
@@ -99,6 +99,7 @@ func TestNewExtensionDatabaseError(t *testing.T) {
 	m.On("Slogger").Return(multislogger.NewNopLogger()).Maybe()
 	m.On("DistributedForwardingInterval").Maybe().Return(60 * time.Second)
 	m.On("RegisterChangeObserver", testifymock.Anything, testifymock.Anything).Maybe().Return()
+	m.On("ReadEnrollSecret").Maybe().Return("enroll_secret", nil)
 
 	// close the DB connection here to trigger the error
 	require.NoError(t, db.Close())
@@ -382,11 +383,19 @@ func TestExtensionGenerateConfigsEnrollmentInvalid(t *testing.T) {
 }
 
 func TestGenerateConfigs_CannotEnrollYet(t *testing.T) {
+	expectedNodeKey := "new_node_key"
+	configVal := `{"foo":"bar","options":{"distributed_interval":5,"verbose":true}}`
 	s := &mock.KolideService{
 		RequestConfigFunc: func(ctx context.Context, nodeKey string) (string, bool, error) {
+			if nodeKey == expectedNodeKey {
+				return configVal, false, nil
+			}
 			// Returns node_invalid
 			return "", true, nil
 		},
+		RequestEnrollmentFunc: func(ctx context.Context, enrollSecret, hostIdentifier string, details service.EnrollmentDetails) (string, bool, error) {
+			return expectedNodeKey, false, nil
+		},
 	}
 
 	k := mocks.NewKnapsack(t)
@@ -395,14 +404,88 @@ func TestGenerateConfigs_CannotEnrollYet(t *testing.T) {
 	k.On("ConfigStore").Return(storageci.NewStore(t, multislogger.NewNopLogger(), storage.ConfigStore.String()))
 	k.On("RegistrationStore").Return(storageci.NewStore(t, multislogger.NewNopLogger(), storage.RegistrationStore.String())).Maybe()
 	k.On("Slogger").Return(multislogger.NewNopLogger())
+	k.On("ReadEnrollSecret").Return("", errors.New("test")).Times(3) // checked once in NewExtension, once in RequireReenroll, and once in Enroll
+	k.On("DistributedForwardingInterval").Maybe().Return(60 * time.Second)
+	k.On("RegisterChangeObserver", testifymock.Anything, testifymock.Anything).Maybe().Return()
+	k.On("DeregisterChangeObserver", testifymock.Anything).Maybe().Return()
+	settingsStore := settingsstoremock.NewSettingsStoreWriter(t)
+	settingsStore.On("WriteSettings").Return(nil)
+
+	e, err := NewExtension(t.Context(), s, settingsStore, k, ulid.New(), ExtensionOpts{})
+	require.Nil(t, err)
+
+	// First, simulate no enrollment secret being available yet
+	configs, err := e.GenerateConfigs(t.Context())
+	assert.NotNil(t, configs)
+	assert.Equal(t, map[string]string{"config": "{}"}, configs)
+	assert.Nil(t, err)
+
+	// Since we can't retrieve the enroll secret, we shouldn't attempt to enroll yet
+	require.False(t, s.RequestEnrollmentFuncInvoked)
+
+	// Now, make an enrollment secret available, and prepare for enrollment
+	k.On("ReadEnrollSecret").Return("test_enrollment_secret", nil).Once()
+	k.On("GetEnrollmentDetails").Return(types.EnrollmentDetails{OSVersion: "1", Hostname: "test"}, nil).Maybe()
+	store := inmemory.NewStore()
+	osqHistory, err := history.InitHistory(store)
+	require.NoError(t, err)
+	k.On("OsqueryHistory").Return(osqHistory).Maybe()
+	k.On("UseCachedDataForScheduledQueries").Return(true).Maybe()
+
+	// Make a config request again -- we should, this time, successfully attempt a reenroll
+	updatedConfigs, err := e.GenerateConfigs(t.Context())
+	require.NoError(t, err)
+	require.Equal(t, map[string]string{"config": configVal}, updatedConfigs)
+
+	// We should have enrolled
+	require.True(t, s.RequestEnrollmentFuncInvoked)
+
+	// Should have tried to request config
+	assert.True(t, s.RequestConfigFuncInvoked)
+
+	// On node invalid response, should attempt to retrieve enroll secret
+	k.AssertExpectations(t)
+}
+
+func TestGenerateConfigs_WorksAfterSecretlessEnrollment(t *testing.T) {
+	nodeKeyFromSecretlessEnrollment := "new_node_key_from_secretless_enrollment"
+	configVal := `{"foo":"bar","options":{"distributed_interval":5,"verbose":true}}`
+	s := &mock.KolideService{
+		RequestConfigFunc: func(ctx context.Context, nodeKey string) (string, bool, error) {
+			if nodeKey != nodeKeyFromSecretlessEnrollment {
+				// Returns node_invalid
+				return "", true, nil
+			}
+			return configVal, false, nil
+		},
+	}
+
+	k := mocks.NewKnapsack(t)
+	k.On("OsquerydPath").Maybe().Return("")
+	k.On("LatestOsquerydPath", testifymock.Anything).Maybe().Return("")
+	configStore, err := storageci.NewStore(t, multislogger.NewNopLogger(), storage.ConfigStore.String())
+	require.NoError(t, err, configStore)
+	k.On("ConfigStore").Return(configStore, nil)
+	k.On("RegistrationStore").Return(storageci.NewStore(t, multislogger.NewNopLogger(), storage.RegistrationStore.String())).Maybe()
+	k.On("Slogger").Return(multislogger.NewNopLogger())
 	k.On("ReadEnrollSecret").Maybe().Return("", errors.New("test"))
 	k.On("DistributedForwardingInterval").Maybe().Return(60 * time.Second)
 	k.On("RegisterChangeObserver", testifymock.Anything, testifymock.Anything).Maybe().Return()
 	k.On("DeregisterChangeObserver", testifymock.Anything).Maybe().Return()
+	store := inmemory.NewStore()
+	osqHistory, err := history.InitHistory(store)
+	require.NoError(t, err)
+	k.On("OsqueryHistory").Return(osqHistory).Maybe()
+	k.On("UseCachedDataForScheduledQueries").Return(true).Maybe()
+	k.On("GetEnrollmentDetails").Return(types.EnrollmentDetails{OSVersion: "1", Hostname: "test"}, nil).Maybe()
+	settingsStore := settingsstoremock.NewSettingsStoreWriter(t)
+	settingsStore.On("WriteSettings").Return(nil)
 
-	e, err := NewExtension(t.Context(), s, settingsstoremock.NewSettingsStoreWriter(t), k, ulid.New(), ExtensionOpts{})
+	e, err := NewExtension(t.Context(), s, settingsStore, k, types.DefaultRegistrationID, ExtensionOpts{})
 	require.Nil(t, err)
 
+	// First request to generate configs -- we shouldn't be able to get anything yet,
+	// since we haven't enrolled.
 	configs, err := e.GenerateConfigs(t.Context())
 	assert.NotNil(t, configs)
 	assert.Equal(t, map[string]string{"config": "{}"}, configs)
@@ -411,10 +494,19 @@ func TestGenerateConfigs_CannotEnrollYet(t *testing.T) {
 	// Should have tried to request config
 	assert.True(t, s.RequestConfigFuncInvoked)
 
+	// Now, set the node key
+	require.NoError(t, configStore.Set([]byte(nodeKeyKey), []byte(nodeKeyFromSecretlessEnrollment)))
+
+	// Try to generate configs again. This time, we should use the correct node key.
+	newConfigs, err := e.GenerateConfigs(t.Context())
+	assert.True(t, s.RequestConfigFuncInvoked)
+	assert.Equal(t, map[string]string{"config": configVal}, newConfigs)
+	assert.Nil(t, err)
+
 	// On node invalid response, should attempt to retrieve enroll secret
 	k.AssertExpectations(t)
 
-	// Since we can't retrieve the enroll secret, we shouldn't attempt to enroll yet
+	// Since we don't have an enroll secret, we shouldn't attempt to enroll
 	assert.False(t, s.RequestEnrollmentFuncInvoked)
 }
 
@@ -740,6 +832,7 @@ func TestExtensionWriteBufferedLogsLimit(t *testing.T) {
 	k.On("DistributedForwardingInterval").Maybe().Return(60 * time.Second)
 	k.On("RegisterChangeObserver", testifymock.Anything, testifymock.Anything).Maybe().Return()
 	k.On("DeregisterChangeObserver", testifymock.Anything).Maybe().Return()
+	k.On("ReadEnrollSecret").Maybe().Return("enroll_secret", nil)
 
 	e, err := NewExtension(t.Context(), m, settingsstoremock.NewSettingsStoreWriter(t), k, ulid.New(), ExtensionOpts{
 		MaxBytesPerBatch: 100,
@@ -816,6 +909,7 @@ func TestExtensionWriteBufferedLogsDropsBigLog(t *testing.T) {
 	k.On("DistributedForwardingInterval").Maybe().Return(60 * time.Second)
 	k.On("RegisterChangeObserver", testifymock.Anything, testifymock.Anything).Maybe().Return()
 	k.On("DeregisterChangeObserver", testifymock.Anything).Maybe().Return()
+	k.On("ReadEnrollSecret").Maybe().Return("enroll_secret", nil)
 
 	e, err := NewExtension(t.Context(), m, settingsstoremock.NewSettingsStoreWriter(t), k, ulid.New(), ExtensionOpts{
 		MaxBytesPerBatch: 15,
@@ -906,6 +1000,7 @@ func TestExtensionWriteLogsLoop(t *testing.T) {
 	k.On("DistributedForwardingInterval").Maybe().Return(60 * time.Second)
 	k.On("RegisterChangeObserver", testifymock.Anything, testifymock.Anything).Maybe().Return()
 	k.On("DeregisterChangeObserver", testifymock.Anything).Maybe().Return()
+	k.On("ReadEnrollSecret").Maybe().Return("enroll_secret", nil)
 
 	expectedLoggingInterval := 5 * time.Second
 	e, err := NewExtension(t.Context(), m, settingsstoremock.NewSettingsStoreWriter(t), k, ulid.New(), ExtensionOpts{
@@ -1035,6 +1130,7 @@ func TestExtensionPurgeBufferedLogs(t *testing.T) {
 	k.On("DistributedForwardingInterval").Maybe().Return(60 * time.Second)
 	k.On("RegisterChangeObserver", testifymock.Anything, testifymock.Anything).Maybe().Return()
 	k.On("DeregisterChangeObserver", testifymock.Anything).Maybe().Return()
+	k.On("ReadEnrollSecret").Maybe().Return("enroll_secret", nil)
 
 	maximum := 10
 	e, err := NewExtension(t.Context(), m, settingsstoremock.NewSettingsStoreWriter(t), k, ulid.New(), ExtensionOpts{
@@ -1231,6 +1327,90 @@ func TestGetQueries_Forwarding_RespondsToAccelerationRequest(t *testing.T) {
 	require.Equal(t, expectedQueries, queries.Queries)
 }
 
+func TestGetQueries_WorksWithSecretlessEnrollment(t *testing.T) {
+	nodeKeyFromSecretlessEnrollment := "another_new_node_key_from_secretless_enrollment"
+	expectedQueries := map[string]string{
+		"time":    "select * from time",
+		"version": "select version from osquery_info",
+	}
+	s := &mock.KolideService{
+		RequestQueriesFunc: func(ctx context.Context, nodeKey string) (*distributed.GetQueriesResult, bool, error) {
+			if nodeKey == nodeKeyFromSecretlessEnrollment {
+				return &distributed.GetQueriesResult{
+					Queries: expectedQueries,
+				}, false, nil
+			}
+
+			return nil, true, nil
+		},
+	}
+
+	k := mocks.NewKnapsack(t)
+	k.On("OsquerydPath").Maybe().Return("")
+	k.On("LatestOsquerydPath", testifymock.Anything).Maybe().Return("")
+	configStore, err := storageci.NewStore(t, multislogger.NewNopLogger(), storage.ConfigStore.String())
+	require.NoError(t, err, configStore)
+	k.On("ConfigStore").Return(configStore, nil)
+	k.On("RegistrationStore").Return(storageci.NewStore(t, multislogger.NewNopLogger(), storage.RegistrationStore.String())).Maybe()
+	k.On("Slogger").Return(multislogger.NewNopLogger())
+	k.On("ReadEnrollSecret").Maybe().Return("", errors.New("test"))
+	k.On("DistributedForwardingInterval").Maybe().Return(60 * time.Second)
+	k.On("RegisterChangeObserver", testifymock.Anything, testifymock.Anything).Maybe().Return()
+	k.On("DeregisterChangeObserver", testifymock.Anything).Maybe().Return()
+	store := inmemory.NewStore()
+	osqHistory, err := history.InitHistory(store)
+	require.NoError(t, err)
+	k.On("OsqueryHistory").Return(osqHistory).Maybe()
+	k.On("UseCachedDataForScheduledQueries").Return(true).Maybe()
+	k.On("GetEnrollmentDetails").Return(types.EnrollmentDetails{OSVersion: "1", Hostname: "test"}, nil).Maybe()
+	settingsStore := settingsstoremock.NewSettingsStoreWriter(t)
+
+	e, err := NewExtension(t.Context(), s, settingsStore, k, types.DefaultRegistrationID, ExtensionOpts{})
+	require.Nil(t, err)
+
+	// First request to generate configs -- we shouldn't be able to get anything yet,
+	// since we haven't enrolled.
+	_, err = e.GetQueries(t.Context())
+	require.Error(t, err, "should not have been able to get queries while unenrolled")
+
+	// Should have tried to request queries
+	assert.True(t, s.RequestQueriesFuncInvoked)
+
+	// Now, fire off a bunch of sequential GetQueries requests, so that we'll (probably) have one
+	// processing while we simulate secretless enrollment completing
+	resultChan := make(chan struct{}, 100)
+	go func() {
+		for range 100 {
+			_, _ = e.GetQueries(t.Context())
+			resultChan <- struct{}{}
+		}
+	}()
+
+	// Now, set the node key
+	require.NoError(t, configStore.Set([]byte(nodeKeyKey), []byte(nodeKeyFromSecretlessEnrollment)))
+
+	// Wait for our previous queries to complete
+	for range 100 {
+		select {
+		case <-resultChan:
+			// Nothing to do here
+		case <-time.After(1 * time.Minute):
+			t.FailNow()
+		}
+	}
+
+	// Now, try to get queries -- we should be enrolled, so we should be able to get
+	// queries with the correct node key now.
+	newQueries, err := e.GetQueries(t.Context())
+	require.NoError(t, err)
+	require.Equal(t, expectedQueries, newQueries.Queries)
+
+	// Since we don't have an enroll secret, we shouldn't ever attempt to enroll
+	assert.False(t, s.RequestEnrollmentFuncInvoked)
+
+	k.AssertExpectations(t)
+}
+
 func TestExtensionWriteResultsTransportError(t *testing.T) {
 	m := &mock.KolideService{
 		PublishResultsFunc: func(ctx context.Context, nodeKey string, results []distributed.Result) (string, string, bool, error) {