From 89c26e7d37e797fccf09e226886408d1a4faf3ac Mon Sep 17 00:00:00 2001
From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com>
Date: Wed, 13 May 2026 00:00:14 +0000
Subject: [PATCH 1/4] UI and BYOD fixes
---
.../GlobalActivityItem/GlobalActivityItem.tsx | 2 +-
.../InstallSoftware/InstallSoftware.tests.tsx | 22 +++++
.../cards/InstallSoftware/InstallSoftware.tsx | 6 +-
.../InstallSoftwareForm.tsx | 2 +-
.../CanceledSetupExperienceActivityItem.tsx | 2 +-
server/datastore/mysql/setup_experience.go | 84 ++++++++++++++-----
.../datastore/mysql/setup_experience_test.go | 78 +++++++++++++++++
server/service/setup_experience.go | 18 ++++
server/service/setup_experience_test.go | 64 ++++++++++++++
9 files changed, 255 insertions(+), 23 deletions(-)
diff --git a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx
index 0218e4bdd5b..d6b7b1b175d 100644
--- a/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx
+++ b/frontend/pages/DashboardPage/cards/ActivityFeed/GlobalActivityItem/GlobalActivityItem.tsx
@@ -1671,7 +1671,7 @@ const TAGGED_TEMPLATES = {
<>
{" "}
canceled setup experience on {hostName} because {title}{" "}
- failed to install.
+ failed to install. End user was asked to restart.
>
);
},
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx
index 1fe3728c1e1..391d3375b7f 100644
--- a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx
@@ -74,6 +74,28 @@ describe("InstallSoftware", () => {
expect(await screen.findByRole("button", { name: "Save" })).toBeVisible();
});
+ it.each(["windows", "linux"] as const)(
+ "renders the %s-tab page description without 'automatically'",
+ async (platform) => {
+ setupMdmConfigured();
+ const render = createCustomRenderer({
+ withBackendMock: true,
+ });
+
+ render(
+
+ );
+
+ expect(
+ screen.getByText("Install software on hosts that enroll to Fleet.")
+ ).toBeVisible();
+ }
+ );
+
it("renders the Android empty state with correct messaging", async () => {
mockServer.use(createSetupExperienceSoftwareHandler());
mockServer.use(
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tsx
index 9186319ca2b..91042b87f21 100644
--- a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tsx
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tsx
@@ -226,7 +226,11 @@ const InstallSoftware = ({
/>
{isLoadingConfig ? (
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareForm/InstallSoftwareForm.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareForm/InstallSoftwareForm.tsx
index 3726030a392..474fe6a3066 100644
--- a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareForm/InstallSoftwareForm.tsx
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/components/InstallSoftwareForm/InstallSoftwareForm.tsx
@@ -323,7 +323,7 @@ const InstallSoftwareForm = ({
{activity.actor_full_name ?? "Fleet"} canceled setup experience
on this host because {activity.details.software_title} failed to
- install.
+ install. End user was asked to restart.
>
);
diff --git a/server/datastore/mysql/setup_experience.go b/server/datastore/mysql/setup_experience.go
index 3cdce089978..507da2389c3 100644
--- a/server/datastore/mysql/setup_experience.go
+++ b/server/datastore/mysql/setup_experience.go
@@ -81,32 +81,78 @@ func (ds *Datastore) enqueueSetupExperienceItems(ctx context.Context, hostPlatfo
// If the host was enrolled more than 24 hours ago, don't enqueue any items.
// Note: if the last enroll date is our "zero date" (1/1/2000), treat it as if it's never enrolled.
if lastEnrolledAt.Valid && lastEnrolledAt.Time.Before(time.Now().Add(-24*time.Hour)) && lastEnrolledAt.Time.After(time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC)) {
- // On Windows, the 24h-old-host guard races with last_enrolled_at on re-Autopilot:
- // orbit calls SetupExperienceInit before the new last_enrolled_at lands, so a
- // previously-enrolled host that's mid-Autopilot-OOBE looks "old" and gets skipped
- // even though it IS in ESP and we DO want setup-experience to run. Fall back to a
- // direct check of mdm_windows_enrollments.awaiting_configuration: if the host is
- // in Pending/Active, it's actively in ESP, bypass the age guard.
- // mdm_windows_enrollments.host_uuid stores the Fleet host UUID (hosts.uuid), but the
- // hostUUID parameter here comes from fleet.HostUUIDForSetupExperience, which on Windows
- // resolves to OsqueryHostID. Resolve via JOIN and match either identifier so the lookup
- // works regardless of how OsqueryHostID and the Fleet host UUID relate (this depends on
- // the osquery host_identifier mode).
+ // On Windows, the 24h-old-host guard races with last_enrolled_at when a previously-enrolled
+ // device re-enrolls (Autopilot wipe, Entra OOBE on a recycled VM, BYOD reconnect, etc.). Orbit
+ // calls SetupExperienceInit shortly after orbit/enroll, before EnrollOrbit's last_enrolled_at
+ // update has committed, so the host's last_enrolled_at still reflects the prior enrollment and
+ // looks "old" even though this IS a fresh enrollment we want to run setup-experience for.
+ //
+ // Fall back to mdm_windows_enrollments to detect a genuine re-enrollment. Two signals work:
+ // 1. awaiting_configuration in Pending/Active: device is actively in ESP. Covers re-Autopilot
+ // and re-Entra-OOBE.
+ // 2. mdm_windows_enrollments.created_at within the last few minutes: a fresh MDM enrollment
+ // row was just inserted. Covers BYOD (which never enters awaiting_configuration) plus the
+ // OOBE cases above as a belt-and-suspenders fallback.
+ //
+ // Two lookup strategies (we try them in order):
+ // a. JOIN on mwe.host_uuid = hosts.uuid. Works once osquery's directIngestMDMDeviceIDWindows
+ // has run, which links the two tables. For re-Autopilot/re-Entra-OOBE, this happens at
+ // enrollment time via the EUA token path so the JOIN works at setup_experience/init time.
+ // b. JOIN on mwe.device_name = hosts.computer_name (case-insensitive via default collation).
+ // Works for BYOD where the host_uuid linkage hasn't been populated yet at init time:
+ // mwe rows have device_name set by Windows during MDM enrollment, and hosts.computer_name
+ // is set by orbit/enroll just before init runs.
+ //
+ // The original #35717 protection (skip setup-experience for a fleetd upgrade on a long-running
+ // host) is preserved: a fleetd MSI upgrade doesn't create a new mdm_windows_enrollments row, so
+ // neither lookup finds a fresh row.
+ //
+ // hostUUID here comes from fleet.HostUUIDForSetupExperience, which on Windows resolves to
+ // OsqueryHostID. Match either identifier on the hosts side so the lookup works regardless of
+ // the osquery host_identifier mode.
if hostPlatform == "windows" {
- var awaiting fleet.WindowsMDMAwaitingConfiguration
- stmtAwaiting := `
- SELECT mwe.awaiting_configuration
+ var mdmState struct {
+ AwaitingConfiguration fleet.WindowsMDMAwaitingConfiguration `db:"awaiting_configuration"`
+ CreatedAt time.Time `db:"created_at"`
+ }
+ // Primary lookup: JOIN by mwe.host_uuid (the populated link).
+ stmtByHostUUID := `
+ SELECT mwe.awaiting_configuration, mwe.created_at
FROM mdm_windows_enrollments mwe
JOIN hosts h ON mwe.host_uuid = h.uuid
WHERE (h.osquery_host_id = ? OR h.uuid = ?) AND h.platform = 'windows'
ORDER BY mwe.created_at DESC, mwe.id DESC
LIMIT 1
`
- if err := sqlx.GetContext(ctx, ds.reader(ctx), &awaiting, stmtAwaiting, hostUUID, hostUUID); err != nil && !errors.Is(err, sql.ErrNoRows) {
- return false, ctxerr.Wrap(ctx, err, "checking windows awaiting_configuration for setup experience age guard")
- } else if err == nil && awaiting != fleet.WindowsMDMAwaitingConfigurationNone {
- ds.logger.DebugContext(ctx, "Windows host enrolled >24h ago but is in awaiting_configuration; running setup experience for re-Autopilot",
- "host_uuid", hostUUID, "awaiting_configuration", awaiting)
+ found := false
+ if err := sqlx.GetContext(ctx, ds.reader(ctx), &mdmState, stmtByHostUUID, hostUUID, hostUUID); err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return false, ctxerr.Wrap(ctx, err, "checking windows mdm enrollment state by host_uuid for setup experience age guard")
+ } else if err == nil {
+ found = true
+ }
+ // Secondary lookup: JOIN by device_name = computer_name. Only needed when the primary
+ // link is not yet populated, which is the BYOD-before-osquery-ingest case.
+ if !found {
+ stmtByName := `
+ SELECT mwe.awaiting_configuration, mwe.created_at
+ FROM mdm_windows_enrollments mwe
+ JOIN hosts h ON mwe.device_name = h.computer_name
+ WHERE (h.osquery_host_id = ? OR h.uuid = ?) AND h.platform = 'windows' AND h.computer_name <> ''
+ ORDER BY mwe.created_at DESC, mwe.id DESC
+ LIMIT 1
+ `
+ if err := sqlx.GetContext(ctx, ds.reader(ctx), &mdmState, stmtByName, hostUUID, hostUUID); err != nil && !errors.Is(err, sql.ErrNoRows) {
+ return false, ctxerr.Wrap(ctx, err, "checking windows mdm enrollment state by device_name for setup experience age guard")
+ } else if err == nil {
+ found = true
+ }
+ }
+ freshEnrollmentWindow := 5 * time.Minute
+ if found && (mdmState.AwaitingConfiguration != fleet.WindowsMDMAwaitingConfigurationNone || time.Since(mdmState.CreatedAt) < freshEnrollmentWindow) {
+ ds.logger.DebugContext(ctx, "Windows host enrolled >24h ago but has fresh MDM enrollment state; running setup experience for re-enrollment",
+ "host_uuid", hostUUID,
+ "awaiting_configuration", mdmState.AwaitingConfiguration,
+ "mdm_enrollment_created_at", mdmState.CreatedAt)
// fall through to enqueue
} else {
ds.logger.DebugContext(ctx, "Host enrolled more than 24 hours ago, skipping enqueueing setup experience items", "host_uuid", hostUUID, "platform_like", hostPlatformLike, "last_enrolled_at", lastEnrolledAt.Time)
diff --git a/server/datastore/mysql/setup_experience_test.go b/server/datastore/mysql/setup_experience_test.go
index 069dff1618f..282901caa62 100644
--- a/server/datastore/mysql/setup_experience_test.go
+++ b/server/datastore/mysql/setup_experience_test.go
@@ -369,6 +369,84 @@ func testEnqueueSetupExperienceItemsWindows(t *testing.T, ds *Datastore) {
require.NoError(t, err)
require.True(t, anythingEnqueued,
"re-Autopilot of an existing host (>24h old) with awaiting_configuration!=None must bypass the age guard")
+
+ // Re-BYOD of an existing host: last_enrolled_at is >24h old AND the host's BYOD enrollment never
+ // enters awaiting_configuration (not_in_oobe=1). The freshly-created mdm_windows_enrollments row
+ // is the signal that this IS a real re-enrollment we want setup-experience for.
+ host4UUID := "44444444-4444-4444-4444-444444444444"
+ _, err = ds.NewHost(ctx, &fleet.Host{
+ Hostname: "windows-test-4-rebyod",
+ OsqueryHostID: ptr.String("osquery-windows-4"),
+ NodeKey: ptr.String("node-key-windows-4"),
+ UUID: host4UUID,
+ Platform: "windows",
+ HardwareSerial: "654321d-4",
+ })
+ require.NoError(t, err)
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Now().Add(-25*time.Hour), host4UUID)
+ return err
+ })
+ require.NoError(t, ds.MDMWindowsInsertEnrolledDevice(ctx, &fleet.MDMWindowsEnrolledDevice{
+ MDMDeviceID: "device-host4",
+ MDMHardwareID: "hw-host4",
+ MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
+ MDMDeviceType: "CIMClient_Windows",
+ MDMDeviceName: "DESKTOP-H4",
+ MDMEnrollType: "ProgrammaticEnrollment",
+ MDMEnrollProtoVersion: "5.0",
+ MDMEnrollClientVersion: "10.0.19045.2965",
+ MDMNotInOOBE: true,
+ HostUUID: host4UUID,
+ AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationNone,
+ }))
+
+ anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "windows", "windows", host4UUID, team1.ID)
+ require.NoError(t, err)
+ require.True(t, anythingEnqueued,
+ "re-BYOD of an existing host (>24h old) with a fresh mdm_windows_enrollments row must bypass the age guard")
+
+ // Original #35717 protection: a fleetd MSI upgrade on a long-running host does NOT create a new
+ // mdm_windows_enrollments row, so the existing one is also old. Both fallback signals must miss,
+ // and the age guard must still skip enqueueing.
+ host5UUID := "55555555-5555-5555-5555-555555555555"
+ _, err = ds.NewHost(ctx, &fleet.Host{
+ Hostname: "windows-test-5-fleetd-upgrade",
+ OsqueryHostID: ptr.String("osquery-windows-5"),
+ NodeKey: ptr.String("node-key-windows-5"),
+ UUID: host5UUID,
+ Platform: "windows",
+ HardwareSerial: "654321e-5",
+ })
+ require.NoError(t, err)
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Now().Add(-25*time.Hour), host5UUID)
+ return err
+ })
+ require.NoError(t, ds.MDMWindowsInsertEnrolledDevice(ctx, &fleet.MDMWindowsEnrolledDevice{
+ MDMDeviceID: "device-host5",
+ MDMHardwareID: "hw-host5",
+ MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
+ MDMDeviceType: "CIMClient_Windows",
+ MDMDeviceName: "DESKTOP-H5",
+ MDMEnrollType: "ProgrammaticEnrollment",
+ MDMEnrollProtoVersion: "5.0",
+ MDMEnrollClientVersion: "10.0.19045.2965",
+ MDMNotInOOBE: true,
+ HostUUID: host5UUID,
+ AwaitingConfiguration: fleet.WindowsMDMAwaitingConfigurationNone,
+ }))
+ // Backdate the MDM enrollment row beyond the freshEnrollmentWindow (5m) to simulate a long-running
+ // host whose orbit just started supporting setup-experience.
+ ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
+ _, err := q.ExecContext(ctx, "UPDATE mdm_windows_enrollments SET created_at = ? WHERE host_uuid = ?", time.Now().Add(-72*time.Hour), host5UUID)
+ return err
+ })
+
+ anythingEnqueued, err = ds.EnqueueSetupExperienceItems(ctx, "windows", "windows", host5UUID, team1.ID)
+ require.NoError(t, err)
+ require.False(t, anythingEnqueued,
+ "long-running host with a stale mdm_windows_enrollments row (e.g. fleetd MSI upgrade per #35717) must still skip enqueueing")
}
func testEnqueueSetupExperienceItems(t *testing.T, ds *Datastore) {
diff --git a/server/service/setup_experience.go b/server/service/setup_experience.go
index 00203aa7cdf..000d9318681 100644
--- a/server/service/setup_experience.go
+++ b/server/service/setup_experience.go
@@ -282,6 +282,24 @@ func maybeCancelPendingSetupExperienceSteps(ctx context.Context, ds fleet.Datast
if !requireAllSoftware {
return nil
}
+
+ // Windows BYOD enrollments do not participate in the setup-experience cancel flow.
+ // The Enrollment Status Page (ESP) is the user-facing surface that motivates cancellation,
+ // and BYOD hosts (Settings > Accounts > Access work or school > Connect, persisted as
+ // mdm_windows_enrollments.not_in_oobe=1) never render the ESP. Without that UI, cancelling
+ // remaining steps just silently disrupts the install queue with no signal to the end user,
+ // which is the opposite of the require_all_software_windows setting's intent. The failing
+ // install still reports as Failed in host details; the other queued installs simply run
+ // independently, as they would with the setting off. The setting acts as an OOBE-gate only.
+ if host.Platform == "windows" {
+ device, err := ds.MDMWindowsGetEnrolledDeviceWithHostUUID(ctx, host.UUID)
+ if err != nil && !fleet.IsNotFound(err) {
+ return ctxerr.Wrap(ctx, err, "load windows enrollment for byod check")
+ }
+ if device != nil && device.MDMNotInOOBE {
+ return nil
+ }
+ }
hostUUID, err := fleet.HostUUIDForSetupExperience(host)
if err != nil {
return ctxerr.Wrap(ctx, err, "failed to get host's UUID for the setup experience")
diff --git a/server/service/setup_experience_test.go b/server/service/setup_experience_test.go
index d83db103c7b..cc99ffba8c3 100644
--- a/server/service/setup_experience_test.go
+++ b/server/service/setup_experience_test.go
@@ -741,6 +741,12 @@ func TestMaybeUpdateSetupExperience(t *testing.T) {
},
}, nil
}
+ // Windows BYOD short-circuit: this test exercises the OOBE/Autopilot path,
+ // so MDMNotInOOBE=false. The BYOD case (NotInOobe=true → no cancel, no
+ // activity) is covered in its own subtest below.
+ ds.MDMWindowsGetEnrolledDeviceWithHostUUIDFunc = func(ctx context.Context, hUUID string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ return &fleet.MDMWindowsEnrolledDevice{HostUUID: hUUID, MDMNotInOOBE: false}, nil
+ }
installerID := uint(20)
ds.ListSetupExperienceResultsByHostUUIDFunc = func(ctx context.Context, hUUID string, tID uint) ([]*fleet.SetupExperienceStatusResult, error) {
@@ -804,6 +810,64 @@ func TestMaybeUpdateSetupExperience(t *testing.T) {
require.Equal(t, failedSoftwareTitleID, canceledActivity.SoftwareTitleID)
})
+ t.Run("windows BYOD software install failure with require_all_software_windows=true does NOT emit activity or cancel", func(t *testing.T) {
+ // Windows BYOD enrollments (mdm_windows_enrollments.not_in_oobe=1) never see the ESP, so
+ // the require_all_software_windows cancel behavior is gated to OOBE/Autopilot enrollments
+ // only. A software-install failure on a BYOD host must report as Failed in the install
+ // activity feed but must NOT trigger maybeCancelPendingSetupExperienceSteps' cancel +
+ // canceled_setup_experience emission.
+ teamID := uint(1)
+ osqueryHostID := "windows-byod-osquery-id"
+
+ ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ return true, nil
+ }
+ ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) {
+ return &fleet.Host{
+ ID: 4, UUID: hostUUID, Platform: "windows",
+ TeamID: &teamID, OsqueryHostID: &osqueryHostID,
+ }, nil
+ }
+ ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
+ return &fleet.TeamLite{
+ ID: teamID,
+ Config: fleet.TeamConfigLite{
+ MDM: fleet.TeamMDM{
+ MacOSSetup: fleet.MacOSSetup{
+ RequireAllSoftwareWindows: true,
+ },
+ },
+ },
+ }, nil
+ }
+ ds.MDMWindowsGetEnrolledDeviceWithHostUUIDFunc = func(ctx context.Context, hUUID string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ return &fleet.MDMWindowsEnrolledDevice{HostUUID: hUUID, MDMNotInOOBE: true}, nil
+ }
+ ds.CancelHostUpcomingActivityFuncInvoked = false
+ ds.CancelPendingSetupExperienceStepsFuncInvoked = false
+ ds.ListSetupExperienceResultsByHostUUIDFuncInvoked = false
+
+ var activityFnCalled bool
+ activityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
+ activityFnCalled = true
+ return nil
+ }
+
+ result := fleet.SetupExperienceSoftwareInstallResult{
+ HostUUID: hostUUID,
+ ExecutionID: softwareUUID,
+ InstallerStatus: fleet.SoftwareInstallFailed,
+ }
+ updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, activityFn)
+ require.NoError(t, err)
+ require.True(t, updated, "installer status row must still be updated to failure on BYOD")
+ require.False(t, activityFnCalled, "BYOD must not emit canceled_setup_experience even with require_all=true")
+ require.False(t, ds.CancelPendingSetupExperienceStepsFuncInvoked, "BYOD must not cancel pending setup-experience steps")
+ require.False(t, ds.CancelHostUpcomingActivityFuncInvoked, "BYOD must not cancel upcoming activities")
+ require.False(t, ds.ListSetupExperienceResultsByHostUUIDFuncInvoked,
+ "BYOD short-circuit must early-return before listing setup-experience results")
+ })
+
t.Run("software install failure with require_all=false does not emit activity or cancel", func(t *testing.T) {
// Spec invariant: when require_all_software (macOS) /
// require_all_software_windows (Windows) is false, a software
From 1c5b6bccf4cb86d985122daa160891bcf990a7ee Mon Sep 17 00:00:00 2001
From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com>
Date: Wed, 13 May 2026 00:24:06 +0000
Subject: [PATCH 2/4] Code review fixes
---
.../InstallSoftware/InstallSoftware.tests.tsx | 5 ++
server/datastore/mysql/microsoft_mdm.go | 43 ++++++++++++
server/datastore/mysql/setup_experience.go | 14 +++-
server/fleet/datastore.go | 6 ++
server/mock/datastore_mock.go | 12 ++++
server/service/setup_experience.go | 12 ++++
server/service/setup_experience_test.go | 66 +++++++++++++++++++
7 files changed, 157 insertions(+), 1 deletion(-)
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx
index 391d3375b7f..d00af67c21d 100644
--- a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx
@@ -93,6 +93,11 @@ describe("InstallSoftware", () => {
expect(
screen.getByText("Install software on hosts that enroll to Fleet.")
).toBeVisible();
+ expect(
+ screen.queryByText(
+ "Install software on hosts that automatically enroll to Fleet."
+ )
+ ).not.toBeInTheDocument();
}
);
diff --git a/server/datastore/mysql/microsoft_mdm.go b/server/datastore/mysql/microsoft_mdm.go
index b5f67f62520..e8cc2e33d1c 100644
--- a/server/datastore/mysql/microsoft_mdm.go
+++ b/server/datastore/mysql/microsoft_mdm.go
@@ -133,6 +133,49 @@ func (ds *Datastore) MDMWindowsGetEnrolledDeviceWithHostUUID(ctx context.Context
return &winMDMDevice, nil
}
+// MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName fetches the most recent Windows MDM enrollment whose host_uuid is
+// not yet populated and whose device_name matches the given computer name. This is the BYOD-before-osquery-ingest
+// fallback used by callers (e.g. the setup-experience cancel flow) that need to consult enrollment state during the
+// brief window between orbit/enroll and osquery's directIngestMDMDeviceIDWindows linking host_uuid. Constraining on
+// the empty host_uuid avoids matching rows already linked to a different host that happens to share the same Windows
+// computer name.
+func (ds *Datastore) MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx context.Context, deviceName string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ if deviceName == "" {
+ return nil, ctxerr.Wrap(ctx, notFound("MDMWindowsEnrolledDevice").WithMessage("empty device name"))
+ }
+ stmt := `SELECT
+ id,
+ mdm_device_id,
+ mdm_hardware_id,
+ device_state,
+ device_type,
+ device_name,
+ enroll_type,
+ enroll_user_id,
+ enroll_proto_version,
+ enroll_client_version,
+ not_in_oobe,
+ awaiting_configuration,
+ awaiting_configuration_at,
+ credentials_hash,
+ credentials_acknowledged,
+ created_at,
+ updated_at,
+ host_uuid
+ FROM mdm_windows_enrollments
+ WHERE device_name = ? AND (host_uuid IS NULL OR host_uuid = '')
+ ORDER BY created_at DESC, id DESC LIMIT 1`
+
+ var winMDMDevice fleet.MDMWindowsEnrolledDevice
+ if err := sqlx.GetContext(ctx, ds.writer(ctx), &winMDMDevice, stmt, deviceName); err != nil {
+ if err == sql.ErrNoRows {
+ return nil, ctxerr.Wrap(ctx, notFound("MDMWindowsEnrolledDevice").WithMessage(deviceName))
+ }
+ return nil, ctxerr.Wrap(ctx, err, "get MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName")
+ }
+ return &winMDMDevice, nil
+}
+
// HasWindowsSetupExperienceItemsForTeam returns true if any active Windows setup-experience software
// installers with install_during_setup=TRUE are configured for the given team. teamID=0 means "no team /
// global", matching the value EnqueueSetupExperienceItems passes in for hosts on no team.
diff --git a/server/datastore/mysql/setup_experience.go b/server/datastore/mysql/setup_experience.go
index 507da2389c3..c1da41f94ad 100644
--- a/server/datastore/mysql/setup_experience.go
+++ b/server/datastore/mysql/setup_experience.go
@@ -132,12 +132,24 @@ func (ds *Datastore) enqueueSetupExperienceItems(ctx context.Context, hostPlatfo
}
// Secondary lookup: JOIN by device_name = computer_name. Only needed when the primary
// link is not yet populated, which is the BYOD-before-osquery-ingest case.
+ //
+ // Cross-host collision protection: also constrain on mwe.host_uuid so we reject rows
+ // already linked to a different host (e.g. another device on the network shares a Windows
+ // computer name and has finished osquery ingest). The remaining cases are mwe.host_uuid
+ // matching our host (would have been caught by the primary lookup; covered here for
+ // completeness) or mwe.host_uuid still empty (the fresh re-BYOD case we want). A residual
+ // edge case is two hosts sharing the same computer_name both freshly enrolling within the
+ // 5-minute window with neither linked yet -- a narrow collision we accept; the time
+ // window + ORDER BY DESC limits the blast radius to misattributing one bypass per pair.
if !found {
stmtByName := `
SELECT mwe.awaiting_configuration, mwe.created_at
FROM mdm_windows_enrollments mwe
JOIN hosts h ON mwe.device_name = h.computer_name
- WHERE (h.osquery_host_id = ? OR h.uuid = ?) AND h.platform = 'windows' AND h.computer_name <> ''
+ WHERE (h.osquery_host_id = ? OR h.uuid = ?)
+ AND h.platform = 'windows'
+ AND h.computer_name <> ''
+ AND (mwe.host_uuid = h.uuid OR mwe.host_uuid IS NULL OR mwe.host_uuid = '')
ORDER BY mwe.created_at DESC, mwe.id DESC
LIMIT 1
`
diff --git a/server/fleet/datastore.go b/server/fleet/datastore.go
index d6a9e4f6d70..3f7ab96dd4b 100644
--- a/server/fleet/datastore.go
+++ b/server/fleet/datastore.go
@@ -2042,6 +2042,12 @@ type Datastore interface {
// MDMWindowsGetEnrolledDeviceWithHostUUID returns the MDMWindowsEnrolledDevice information for a given HostUUID
MDMWindowsGetEnrolledDeviceWithHostUUID(ctx context.Context, hostUUID string) (*MDMWindowsEnrolledDevice, error)
+ // MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName returns the most recent MDMWindowsEnrolledDevice whose host_uuid
+ // has not yet been populated (i.e. osquery's directIngestMDMDeviceIDWindows has not run since enrollment) and whose
+ // device_name matches the given computer name. Used as a fallback when MDMWindowsGetEnrolledDeviceWithHostUUID can't
+ // find a row because the host->enrollment link has not been resolved yet.
+ MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx context.Context, deviceName string) (*MDMWindowsEnrolledDevice, error)
+
// MDMWindowsDeleteEnrolledDeviceWithDeviceID deletes a give MDMWindowsEnrolledDevice entry from the database using the device id
MDMWindowsDeleteEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) error
diff --git a/server/mock/datastore_mock.go b/server/mock/datastore_mock.go
index ef3242bd67f..550b1ef68f3 100644
--- a/server/mock/datastore_mock.go
+++ b/server/mock/datastore_mock.go
@@ -1295,6 +1295,8 @@ type MDMWindowsGetEnrolledDeviceWithDeviceIDFunc func(ctx context.Context, mdmDe
type MDMWindowsGetEnrolledDeviceWithHostUUIDFunc func(ctx context.Context, hostUUID string) (*fleet.MDMWindowsEnrolledDevice, error)
+type MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFunc func(ctx context.Context, deviceName string) (*fleet.MDMWindowsEnrolledDevice, error)
+
type MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc func(ctx context.Context, mdmDeviceID string) error
type MDMWindowsInsertCommandForHostsFunc func(ctx context.Context, hostUUIDs []string, cmd *fleet.MDMWindowsCommand) error
@@ -3874,6 +3876,9 @@ type DataStore struct {
MDMWindowsGetEnrolledDeviceWithHostUUIDFunc MDMWindowsGetEnrolledDeviceWithHostUUIDFunc
MDMWindowsGetEnrolledDeviceWithHostUUIDFuncInvoked bool
+ MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFunc MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFunc
+ MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFuncInvoked bool
+
MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc MDMWindowsDeleteEnrolledDeviceWithDeviceIDFunc
MDMWindowsDeleteEnrolledDeviceWithDeviceIDFuncInvoked bool
@@ -9334,6 +9339,13 @@ func (s *DataStore) MDMWindowsGetEnrolledDeviceWithHostUUID(ctx context.Context,
return s.MDMWindowsGetEnrolledDeviceWithHostUUIDFunc(ctx, hostUUID)
}
+func (s *DataStore) MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx context.Context, deviceName string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ s.mu.Lock()
+ s.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFuncInvoked = true
+ s.mu.Unlock()
+ return s.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFunc(ctx, deviceName)
+}
+
func (s *DataStore) MDMWindowsDeleteEnrolledDeviceWithDeviceID(ctx context.Context, mdmDeviceID string) error {
s.mu.Lock()
s.MDMWindowsDeleteEnrolledDeviceWithDeviceIDFuncInvoked = true
diff --git a/server/service/setup_experience.go b/server/service/setup_experience.go
index 000d9318681..f28374e7fe8 100644
--- a/server/service/setup_experience.go
+++ b/server/service/setup_experience.go
@@ -291,11 +291,23 @@ func maybeCancelPendingSetupExperienceSteps(ctx context.Context, ds fleet.Datast
// which is the opposite of the require_all_software_windows setting's intent. The failing
// install still reports as Failed in host details; the other queued installs simply run
// independently, as they would with the setting off. The setting acts as an OOBE-gate only.
+ //
+ // The primary lookup matches on mdm_windows_enrollments.host_uuid (populated by osquery's
+ // directIngestMDMDeviceIDWindows). Fast-failing installs can race that ingest, so when the primary
+ // lookup misses we fall back to the most-recent enrollment with an empty host_uuid whose device_name
+ // matches host.ComputerName. Without the fallback a BYOD host that fails an install in the seconds
+ // before osquery links the enrollment would still trigger cancellation, contradicting the gate.
if host.Platform == "windows" {
device, err := ds.MDMWindowsGetEnrolledDeviceWithHostUUID(ctx, host.UUID)
if err != nil && !fleet.IsNotFound(err) {
return ctxerr.Wrap(ctx, err, "load windows enrollment for byod check")
}
+ if device == nil && host.ComputerName != "" {
+ device, err = ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx, host.ComputerName)
+ if err != nil && !fleet.IsNotFound(err) {
+ return ctxerr.Wrap(ctx, err, "load windows enrollment by device name for byod check")
+ }
+ }
if device != nil && device.MDMNotInOOBE {
return nil
}
diff --git a/server/service/setup_experience_test.go b/server/service/setup_experience_test.go
index cc99ffba8c3..311a6c9bfff 100644
--- a/server/service/setup_experience_test.go
+++ b/server/service/setup_experience_test.go
@@ -868,6 +868,72 @@ func TestMaybeUpdateSetupExperience(t *testing.T) {
"BYOD short-circuit must early-return before listing setup-experience results")
})
+ t.Run("windows BYOD short-circuit falls back to device_name when host_uuid not yet linked", func(t *testing.T) {
+ // Race condition: orbit/enroll completes and immediately runs a fast-failing install before
+ // osquery's directIngestMDMDeviceIDWindows has linked mdm_windows_enrollments.host_uuid. In that
+ // window, MDMWindowsGetEnrolledDeviceWithHostUUID returns NotFound. The secondary lookup by
+ // device_name = host.ComputerName must catch the BYOD signal so cancellation is still skipped.
+ teamID := uint(1)
+ osqueryHostID := "windows-byod-osquery-id-race"
+ computerName := "DESKTOP-BYOD-RACE"
+
+ ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ return true, nil
+ }
+ ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) {
+ return &fleet.Host{
+ ID: 5, UUID: hostUUID, Platform: "windows", ComputerName: computerName,
+ TeamID: &teamID, OsqueryHostID: &osqueryHostID,
+ }, nil
+ }
+ ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
+ return &fleet.TeamLite{
+ ID: teamID,
+ Config: fleet.TeamConfigLite{
+ MDM: fleet.TeamMDM{
+ MacOSSetup: fleet.MacOSSetup{
+ RequireAllSoftwareWindows: true,
+ },
+ },
+ },
+ }, nil
+ }
+ // Primary lookup misses (host_uuid not yet linked).
+ ds.MDMWindowsGetEnrolledDeviceWithHostUUIDFunc = func(ctx context.Context, hUUID string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ return nil, ¬FoundError{}
+ }
+ // Secondary lookup by device_name finds the unlinked BYOD enrollment.
+ ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFunc = func(ctx context.Context, deviceName string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ require.Equal(t, computerName, deviceName)
+ return &fleet.MDMWindowsEnrolledDevice{HostUUID: "", MDMNotInOOBE: true, MDMDeviceName: computerName}, nil
+ }
+ ds.CancelHostUpcomingActivityFuncInvoked = false
+ ds.CancelPendingSetupExperienceStepsFuncInvoked = false
+ ds.ListSetupExperienceResultsByHostUUIDFuncInvoked = false
+ ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFuncInvoked = false
+
+ var activityFnCalled bool
+ activityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
+ activityFnCalled = true
+ return nil
+ }
+
+ result := fleet.SetupExperienceSoftwareInstallResult{
+ HostUUID: hostUUID,
+ ExecutionID: softwareUUID,
+ InstallerStatus: fleet.SoftwareInstallFailed,
+ }
+ updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, activityFn)
+ require.NoError(t, err)
+ require.True(t, updated)
+ require.True(t, ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFuncInvoked,
+ "secondary device_name lookup must run when host_uuid lookup misses")
+ require.False(t, activityFnCalled, "race-window BYOD must still NOT emit canceled_setup_experience")
+ require.False(t, ds.CancelPendingSetupExperienceStepsFuncInvoked, "race-window BYOD must NOT cancel")
+ require.False(t, ds.CancelHostUpcomingActivityFuncInvoked, "race-window BYOD must NOT cancel upcoming activity")
+ require.False(t, ds.ListSetupExperienceResultsByHostUUIDFuncInvoked, "race-window BYOD must early-return")
+ })
+
t.Run("software install failure with require_all=false does not emit activity or cancel", func(t *testing.T) {
// Spec invariant: when require_all_software (macOS) /
// require_all_software_windows (Windows) is false, a software
From a5de71d02e63f3744271d7d3fc3eec89f54599b2 Mon Sep 17 00:00:00 2001
From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com>
Date: Wed, 13 May 2026 16:48:15 +0000
Subject: [PATCH 3/4] Self-review.
---
.../InstallSoftware/InstallSoftware.tests.tsx | 28 ---
server/datastore/mysql/microsoft_mdm_test.go | 68 +++++++
server/datastore/mysql/setup_experience.go | 16 +-
.../datastore/mysql/setup_experience_test.go | 20 +-
server/service/setup_experience.go | 9 +-
server/service/setup_experience_test.go | 188 +++++++-----------
6 files changed, 148 insertions(+), 181 deletions(-)
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx
index d00af67c21d..2faa82f0fb8 100644
--- a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx
@@ -73,34 +73,6 @@ describe("InstallSoftware", () => {
// The form renders (Save button appears)
expect(await screen.findByRole("button", { name: "Save" })).toBeVisible();
});
-
- it.each(["windows", "linux"] as const)(
- "renders the %s-tab page description without 'automatically'",
- async (platform) => {
- setupMdmConfigured();
- const render = createCustomRenderer({
- withBackendMock: true,
- });
-
- render(
-
- );
-
- expect(
- screen.getByText("Install software on hosts that enroll to Fleet.")
- ).toBeVisible();
- expect(
- screen.queryByText(
- "Install software on hosts that automatically enroll to Fleet."
- )
- ).not.toBeInTheDocument();
- }
- );
-
it("renders the Android empty state with correct messaging", async () => {
mockServer.use(createSetupExperienceSoftwareHandler());
mockServer.use(
diff --git a/server/datastore/mysql/microsoft_mdm_test.go b/server/datastore/mysql/microsoft_mdm_test.go
index 9579f655fad..905e2f262b7 100644
--- a/server/datastore/mysql/microsoft_mdm_test.go
+++ b/server/datastore/mysql/microsoft_mdm_test.go
@@ -67,6 +67,7 @@ func TestMDMWindows(t *testing.T) {
{"TestMDMWindowsProfilesToRemoveSkipsOrphanedHosts", testMDMWindowsProfilesToRemoveSkipsOrphanedHosts},
{"TestMDMWindowsInsertCommandSkipsUnenrolledHosts", testMDMWindowsInsertCommandSkipsUnenrolledHosts},
{"TestCleanupWindowsMDMCommandQueue", testCleanupWindowsMDMCommandQueue},
+ {"TestMDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName", testMDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName},
}
for _, c := range cases {
@@ -6077,3 +6078,70 @@ func testMDMWindowsHasSetupExperienceItems(t *testing.T, ds *Datastore) {
require.False(t, hasItemsB, "team-A installer must not appear for team-B")
})
}
+
+func testMDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(t *testing.T, ds *Datastore) {
+ ctx := t.Context()
+
+ // 1. Empty device name → NotFound.
+ _, err := ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx, "")
+ require.Error(t, err)
+ require.True(t, fleet.IsNotFound(err), "empty device name should return NotFound")
+
+ // 2. No matching row → NotFound.
+ _, err = ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx, "DESKTOP-NONE")
+ require.Error(t, err)
+ require.True(t, fleet.IsNotFound(err))
+
+ // 3. Insert an unlinked enrollment (host_uuid="") with a known device_name; method returns it.
+ deviceName := "DESKTOP-RACE-1"
+ unlinked := &fleet.MDMWindowsEnrolledDevice{
+ MDMDeviceID: uuid.New().String(),
+ MDMHardwareID: uuid.New().String() + uuid.New().String(),
+ MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
+ MDMDeviceType: "CIMClient_Windows",
+ MDMDeviceName: deviceName,
+ MDMEnrollType: "AzureADJoin",
+ MDMEnrollProtoVersion: "5.0",
+ MDMEnrollClientVersion: "10.0.19045.2965",
+ MDMNotInOOBE: true,
+ HostUUID: "",
+ }
+ require.NoError(t, ds.MDMWindowsInsertEnrolledDevice(ctx, unlinked))
+
+ got, err := ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx, deviceName)
+ require.NoError(t, err)
+ require.Equal(t, unlinked.MDMDeviceID, got.MDMDeviceID)
+ require.Empty(t, got.HostUUID)
+ require.True(t, got.MDMNotInOOBE)
+
+ // 4. Linked enrollment with the same name (host_uuid populated) must be excluded.
+ linked := &fleet.MDMWindowsEnrolledDevice{
+ MDMDeviceID: uuid.New().String(),
+ MDMHardwareID: uuid.New().String() + uuid.New().String(),
+ MDMDeviceState: microsoft_mdm.MDMDeviceStateEnrolled,
+ MDMDeviceType: "CIMClient_Windows",
+ MDMDeviceName: deviceName,
+ MDMEnrollType: "AzureADJoin",
+ MDMEnrollProtoVersion: "5.0",
+ MDMEnrollClientVersion: "10.0.19045.2965",
+ MDMNotInOOBE: true,
+ HostUUID: "11111111-1111-1111-1111-111111111111",
+ }
+ require.NoError(t, ds.MDMWindowsInsertEnrolledDevice(ctx, linked))
+
+ got, err = ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx, deviceName)
+ require.NoError(t, err)
+ require.Equal(t, unlinked.MDMDeviceID, got.MDMDeviceID,
+ "must return the unlinked row even when another row with the same device_name is linked")
+
+ // 5. After linking the original row too, no unlinked row remains → NotFound.
+ _, err = ds.writer(ctx).ExecContext(ctx,
+ `UPDATE mdm_windows_enrollments SET host_uuid = ? WHERE mdm_device_id = ?`,
+ "22222222-2222-2222-2222-222222222222", unlinked.MDMDeviceID)
+ require.NoError(t, err)
+
+ _, err = ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceName(ctx, deviceName)
+ require.Error(t, err)
+ require.True(t, fleet.IsNotFound(err),
+ "all matching rows now linked; method must return NotFound")
+}
diff --git a/server/datastore/mysql/setup_experience.go b/server/datastore/mysql/setup_experience.go
index c1da41f94ad..7f0edab9479 100644
--- a/server/datastore/mysql/setup_experience.go
+++ b/server/datastore/mysql/setup_experience.go
@@ -94,15 +94,6 @@ func (ds *Datastore) enqueueSetupExperienceItems(ctx context.Context, hostPlatfo
// row was just inserted. Covers BYOD (which never enters awaiting_configuration) plus the
// OOBE cases above as a belt-and-suspenders fallback.
//
- // Two lookup strategies (we try them in order):
- // a. JOIN on mwe.host_uuid = hosts.uuid. Works once osquery's directIngestMDMDeviceIDWindows
- // has run, which links the two tables. For re-Autopilot/re-Entra-OOBE, this happens at
- // enrollment time via the EUA token path so the JOIN works at setup_experience/init time.
- // b. JOIN on mwe.device_name = hosts.computer_name (case-insensitive via default collation).
- // Works for BYOD where the host_uuid linkage hasn't been populated yet at init time:
- // mwe rows have device_name set by Windows during MDM enrollment, and hosts.computer_name
- // is set by orbit/enroll just before init runs.
- //
// The original #35717 protection (skip setup-experience for a fleetd upgrade on a long-running
// host) is preserved: a fleetd MSI upgrade doesn't create a new mdm_windows_enrollments row, so
// neither lookup finds a fresh row.
@@ -135,12 +126,9 @@ func (ds *Datastore) enqueueSetupExperienceItems(ctx context.Context, hostPlatfo
//
// Cross-host collision protection: also constrain on mwe.host_uuid so we reject rows
// already linked to a different host (e.g. another device on the network shares a Windows
- // computer name and has finished osquery ingest). The remaining cases are mwe.host_uuid
- // matching our host (would have been caught by the primary lookup; covered here for
- // completeness) or mwe.host_uuid still empty (the fresh re-BYOD case we want). A residual
+ // computer name and has finished osquery ingest). A residual
// edge case is two hosts sharing the same computer_name both freshly enrolling within the
- // 5-minute window with neither linked yet -- a narrow collision we accept; the time
- // window + ORDER BY DESC limits the blast radius to misattributing one bypass per pair.
+ // 5-minute window with neither linked yet. Follow-up bug: https://github.com/fleetdm/fleet/issues/45380
if !found {
stmtByName := `
SELECT mwe.awaiting_configuration, mwe.created_at
diff --git a/server/datastore/mysql/setup_experience_test.go b/server/datastore/mysql/setup_experience_test.go
index 282901caa62..c3deaecad88 100644
--- a/server/datastore/mysql/setup_experience_test.go
+++ b/server/datastore/mysql/setup_experience_test.go
@@ -374,15 +374,7 @@ func testEnqueueSetupExperienceItemsWindows(t *testing.T, ds *Datastore) {
// enters awaiting_configuration (not_in_oobe=1). The freshly-created mdm_windows_enrollments row
// is the signal that this IS a real re-enrollment we want setup-experience for.
host4UUID := "44444444-4444-4444-4444-444444444444"
- _, err = ds.NewHost(ctx, &fleet.Host{
- Hostname: "windows-test-4-rebyod",
- OsqueryHostID: ptr.String("osquery-windows-4"),
- NodeKey: ptr.String("node-key-windows-4"),
- UUID: host4UUID,
- Platform: "windows",
- HardwareSerial: "654321d-4",
- })
- require.NoError(t, err)
+ test.NewHost(t, ds, "windows-test-4-rebyod", "", "node-key-windows-4", host4UUID, time.Now(), test.WithPlatform("windows"))
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Now().Add(-25*time.Hour), host4UUID)
return err
@@ -410,15 +402,7 @@ func testEnqueueSetupExperienceItemsWindows(t *testing.T, ds *Datastore) {
// mdm_windows_enrollments row, so the existing one is also old. Both fallback signals must miss,
// and the age guard must still skip enqueueing.
host5UUID := "55555555-5555-5555-5555-555555555555"
- _, err = ds.NewHost(ctx, &fleet.Host{
- Hostname: "windows-test-5-fleetd-upgrade",
- OsqueryHostID: ptr.String("osquery-windows-5"),
- NodeKey: ptr.String("node-key-windows-5"),
- UUID: host5UUID,
- Platform: "windows",
- HardwareSerial: "654321e-5",
- })
- require.NoError(t, err)
+ test.NewHost(t, ds, "windows-test-5-fleetd-upgrade", "", "node-key-windows-5", host5UUID, time.Now(), test.WithPlatform("windows"))
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, "UPDATE hosts SET last_enrolled_at = ? WHERE uuid = ?", time.Now().Add(-25*time.Hour), host5UUID)
return err
diff --git a/server/service/setup_experience.go b/server/service/setup_experience.go
index f28374e7fe8..9ed6d5a27fa 100644
--- a/server/service/setup_experience.go
+++ b/server/service/setup_experience.go
@@ -284,19 +284,12 @@ func maybeCancelPendingSetupExperienceSteps(ctx context.Context, ds fleet.Datast
}
// Windows BYOD enrollments do not participate in the setup-experience cancel flow.
- // The Enrollment Status Page (ESP) is the user-facing surface that motivates cancellation,
- // and BYOD hosts (Settings > Accounts > Access work or school > Connect, persisted as
- // mdm_windows_enrollments.not_in_oobe=1) never render the ESP. Without that UI, cancelling
- // remaining steps just silently disrupts the install queue with no signal to the end user,
- // which is the opposite of the require_all_software_windows setting's intent. The failing
- // install still reports as Failed in host details; the other queued installs simply run
- // independently, as they would with the setting off. The setting acts as an OOBE-gate only.
- //
// The primary lookup matches on mdm_windows_enrollments.host_uuid (populated by osquery's
// directIngestMDMDeviceIDWindows). Fast-failing installs can race that ingest, so when the primary
// lookup misses we fall back to the most-recent enrollment with an empty host_uuid whose device_name
// matches host.ComputerName. Without the fallback a BYOD host that fails an install in the seconds
// before osquery links the enrollment would still trigger cancellation, contradicting the gate.
+ // Follow-up bug: https://github.com/fleetdm/fleet/issues/45380
if host.Platform == "windows" {
device, err := ds.MDMWindowsGetEnrolledDeviceWithHostUUID(ctx, host.UUID)
if err != nil && !fleet.IsNotFound(err) {
diff --git a/server/service/setup_experience_test.go b/server/service/setup_experience_test.go
index 311a6c9bfff..35c8654b33b 100644
--- a/server/service/setup_experience_test.go
+++ b/server/service/setup_experience_test.go
@@ -810,128 +810,90 @@ func TestMaybeUpdateSetupExperience(t *testing.T) {
require.Equal(t, failedSoftwareTitleID, canceledActivity.SoftwareTitleID)
})
- t.Run("windows BYOD software install failure with require_all_software_windows=true does NOT emit activity or cancel", func(t *testing.T) {
- // Windows BYOD enrollments (mdm_windows_enrollments.not_in_oobe=1) never see the ESP, so
- // the require_all_software_windows cancel behavior is gated to OOBE/Autopilot enrollments
- // only. A software-install failure on a BYOD host must report as Failed in the install
- // activity feed but must NOT trigger maybeCancelPendingSetupExperienceSteps' cancel +
- // canceled_setup_experience emission.
+ t.Run("windows BYOD short-circuit prevents cancel + activity (covers both lookup paths)", func(t *testing.T) {
+ // BYOD enrollments (mdm_windows_enrollments.not_in_oobe=1) never see the ESP, so the
+ // require_all_software_windows cancel behavior must be a no-op on BYOD even when a failed
+ // install would normally trigger maybeCancelPendingSetupExperienceSteps. Two subcases cover
+ // the two lookup paths inside the short-circuit:
+ // 1. host_uuid linked: osquery has run directIngestMDMDeviceIDWindows; the primary
+ // MDMWindowsGetEnrolledDeviceWithHostUUID lookup hits.
+ // 2. host_uuid not yet linked: the install reports back before osquery has linked
+ // host_uuid; the primary lookup misses and the secondary device_name fallback fires.
teamID := uint(1)
+ computerName := "DESKTOP-BYOD"
osqueryHostID := "windows-byod-osquery-id"
+ byodDevice := &fleet.MDMWindowsEnrolledDevice{MDMNotInOOBE: true, MDMDeviceName: computerName}
- ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
- return true, nil
- }
- ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) {
- return &fleet.Host{
- ID: 4, UUID: hostUUID, Platform: "windows",
- TeamID: &teamID, OsqueryHostID: &osqueryHostID,
- }, nil
- }
- ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
- return &fleet.TeamLite{
- ID: teamID,
- Config: fleet.TeamConfigLite{
- MDM: fleet.TeamMDM{
- MacOSSetup: fleet.MacOSSetup{
- RequireAllSoftwareWindows: true,
- },
- },
- },
- }, nil
- }
- ds.MDMWindowsGetEnrolledDeviceWithHostUUIDFunc = func(ctx context.Context, hUUID string) (*fleet.MDMWindowsEnrolledDevice, error) {
- return &fleet.MDMWindowsEnrolledDevice{HostUUID: hUUID, MDMNotInOOBE: true}, nil
- }
- ds.CancelHostUpcomingActivityFuncInvoked = false
- ds.CancelPendingSetupExperienceStepsFuncInvoked = false
- ds.ListSetupExperienceResultsByHostUUIDFuncInvoked = false
-
- var activityFnCalled bool
- activityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
- activityFnCalled = true
- return nil
- }
-
- result := fleet.SetupExperienceSoftwareInstallResult{
- HostUUID: hostUUID,
- ExecutionID: softwareUUID,
- InstallerStatus: fleet.SoftwareInstallFailed,
+ cases := []struct {
+ name string
+ primaryFound bool
+ }{
+ {"host_uuid linked (primary lookup hits)", true},
+ {"host_uuid not yet linked (falls back to device_name)", false},
}
- updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, activityFn)
- require.NoError(t, err)
- require.True(t, updated, "installer status row must still be updated to failure on BYOD")
- require.False(t, activityFnCalled, "BYOD must not emit canceled_setup_experience even with require_all=true")
- require.False(t, ds.CancelPendingSetupExperienceStepsFuncInvoked, "BYOD must not cancel pending setup-experience steps")
- require.False(t, ds.CancelHostUpcomingActivityFuncInvoked, "BYOD must not cancel upcoming activities")
- require.False(t, ds.ListSetupExperienceResultsByHostUUIDFuncInvoked,
- "BYOD short-circuit must early-return before listing setup-experience results")
- })
-
- t.Run("windows BYOD short-circuit falls back to device_name when host_uuid not yet linked", func(t *testing.T) {
- // Race condition: orbit/enroll completes and immediately runs a fast-failing install before
- // osquery's directIngestMDMDeviceIDWindows has linked mdm_windows_enrollments.host_uuid. In that
- // window, MDMWindowsGetEnrolledDeviceWithHostUUID returns NotFound. The secondary lookup by
- // device_name = host.ComputerName must catch the BYOD signal so cancellation is still skipped.
- teamID := uint(1)
- osqueryHostID := "windows-byod-osquery-id-race"
- computerName := "DESKTOP-BYOD-RACE"
- ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
- return true, nil
- }
- ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) {
- return &fleet.Host{
- ID: 5, UUID: hostUUID, Platform: "windows", ComputerName: computerName,
- TeamID: &teamID, OsqueryHostID: &osqueryHostID,
- }, nil
- }
- ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
- return &fleet.TeamLite{
- ID: teamID,
- Config: fleet.TeamConfigLite{
- MDM: fleet.TeamMDM{
- MacOSSetup: fleet.MacOSSetup{
- RequireAllSoftwareWindows: true,
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ ds.MaybeUpdateSetupExperienceSoftwareInstallStatusFunc = func(ctx context.Context, hUUID string, executionID string, status fleet.SetupExperienceStatusResultStatus) (bool, error) {
+ return true, nil
+ }
+ ds.HostByIdentifierFunc = func(ctx context.Context, identifier string) (*fleet.Host, error) {
+ return &fleet.Host{
+ ID: 4, UUID: hostUUID, Platform: "windows", ComputerName: computerName,
+ TeamID: &teamID, OsqueryHostID: &osqueryHostID,
+ }, nil
+ }
+ ds.TeamLiteFunc = func(ctx context.Context, tid uint) (*fleet.TeamLite, error) {
+ return &fleet.TeamLite{
+ ID: teamID,
+ Config: fleet.TeamConfigLite{
+ MDM: fleet.TeamMDM{
+ MacOSSetup: fleet.MacOSSetup{RequireAllSoftwareWindows: true},
+ },
},
- },
- },
- }, nil
- }
- // Primary lookup misses (host_uuid not yet linked).
- ds.MDMWindowsGetEnrolledDeviceWithHostUUIDFunc = func(ctx context.Context, hUUID string) (*fleet.MDMWindowsEnrolledDevice, error) {
- return nil, ¬FoundError{}
- }
- // Secondary lookup by device_name finds the unlinked BYOD enrollment.
- ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFunc = func(ctx context.Context, deviceName string) (*fleet.MDMWindowsEnrolledDevice, error) {
- require.Equal(t, computerName, deviceName)
- return &fleet.MDMWindowsEnrolledDevice{HostUUID: "", MDMNotInOOBE: true, MDMDeviceName: computerName}, nil
- }
- ds.CancelHostUpcomingActivityFuncInvoked = false
- ds.CancelPendingSetupExperienceStepsFuncInvoked = false
- ds.ListSetupExperienceResultsByHostUUIDFuncInvoked = false
- ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFuncInvoked = false
+ }, nil
+ }
+ if tc.primaryFound {
+ ds.MDMWindowsGetEnrolledDeviceWithHostUUIDFunc = func(ctx context.Context, hUUID string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ return byodDevice, nil
+ }
+ } else {
+ ds.MDMWindowsGetEnrolledDeviceWithHostUUIDFunc = func(ctx context.Context, hUUID string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ return nil, ¬FoundError{}
+ }
+ ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFunc = func(ctx context.Context, deviceName string) (*fleet.MDMWindowsEnrolledDevice, error) {
+ require.Equal(t, computerName, deviceName)
+ return byodDevice, nil
+ }
+ }
+ ds.CancelHostUpcomingActivityFuncInvoked = false
+ ds.CancelPendingSetupExperienceStepsFuncInvoked = false
+ ds.ListSetupExperienceResultsByHostUUIDFuncInvoked = false
+ ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFuncInvoked = false
- var activityFnCalled bool
- activityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
- activityFnCalled = true
- return nil
- }
+ var activityFnCalled bool
+ activityFn := func(ctx context.Context, user *fleet.User, activity fleet.ActivityDetails) error {
+ activityFnCalled = true
+ return nil
+ }
- result := fleet.SetupExperienceSoftwareInstallResult{
- HostUUID: hostUUID,
- ExecutionID: softwareUUID,
- InstallerStatus: fleet.SoftwareInstallFailed,
+ result := fleet.SetupExperienceSoftwareInstallResult{
+ HostUUID: hostUUID,
+ ExecutionID: softwareUUID,
+ InstallerStatus: fleet.SoftwareInstallFailed,
+ }
+ updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, activityFn)
+ require.NoError(t, err)
+ require.True(t, updated, "installer status row must still be updated to failure on BYOD")
+ require.False(t, activityFnCalled, "BYOD must not emit canceled_setup_experience even with require_all=true")
+ require.False(t, ds.CancelPendingSetupExperienceStepsFuncInvoked, "BYOD must not cancel pending setup-experience steps")
+ require.False(t, ds.CancelHostUpcomingActivityFuncInvoked, "BYOD must not cancel upcoming activities")
+ require.False(t, ds.ListSetupExperienceResultsByHostUUIDFuncInvoked,
+ "BYOD short-circuit must early-return before listing setup-experience results")
+ require.Equal(t, !tc.primaryFound, ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFuncInvoked,
+ "secondary device_name lookup must run iff primary host_uuid lookup missed")
+ })
}
- updated, err := maybeUpdateSetupExperienceStatus(ctx, ds, result, activityFn)
- require.NoError(t, err)
- require.True(t, updated)
- require.True(t, ds.MDMWindowsGetUnlinkedEnrolledDeviceWithDeviceNameFuncInvoked,
- "secondary device_name lookup must run when host_uuid lookup misses")
- require.False(t, activityFnCalled, "race-window BYOD must still NOT emit canceled_setup_experience")
- require.False(t, ds.CancelPendingSetupExperienceStepsFuncInvoked, "race-window BYOD must NOT cancel")
- require.False(t, ds.CancelHostUpcomingActivityFuncInvoked, "race-window BYOD must NOT cancel upcoming activity")
- require.False(t, ds.ListSetupExperienceResultsByHostUUIDFuncInvoked, "race-window BYOD must early-return")
})
t.Run("software install failure with require_all=false does not emit activity or cancel", func(t *testing.T) {
From 086ce795ff56ced812a6bff5680c9a4492c8a070 Mon Sep 17 00:00:00 2001
From: Victor Lyuboslavsky <2685025+getvictor@users.noreply.github.com>
Date: Wed, 13 May 2026 16:49:31 +0000
Subject: [PATCH 4/4] Line
---
.../cards/InstallSoftware/InstallSoftware.tests.tsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx
index 2faa82f0fb8..1fe3728c1e1 100644
--- a/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx
+++ b/frontend/pages/ManageControlsPage/SetupExperience/cards/InstallSoftware/InstallSoftware.tests.tsx
@@ -73,6 +73,7 @@ describe("InstallSoftware", () => {
// The form renders (Save button appears)
expect(await screen.findByRole("button", { name: "Save" })).toBeVisible();
});
+
it("renders the Android empty state with correct messaging", async () => {
mockServer.use(createSetupExperienceSoftwareHandler());
mockServer.use(