Skip to content

[Windows] Self-update resolves update.finished under System32 when runner runs as a service #4619

Description

@HyxiaoGe

Describe the bug

On Windows, the self-update flow uses the relative path update.finished and starts the generated update script without setting ProcessStartInfo.WorkingDirectory.

When the runner is hosted by Windows Service Control Manager, its inherited current directory can be C:\Windows\System32. As a result, the update marker is resolved as:

C:\Windows\System32\update.finished

This caused a self-hosted runner running as NT AUTHORITY\NETWORK SERVICE to fail an otherwise successful update because it could not delete a marker previously created at that shared System32 path.

The behavior was observed while updating runner 2.334.0 to 2.336.0. The same relative-path logic is present in 2.336.0 and in current main at commit 99e01149303b194e08778cdc9c03fa2703d03fb2b.

To Reproduce

  1. Configure an x64 Windows self-hosted runner to run as a Windows service under a least-privilege service identity such as NT AUTHORITY\NETWORK SERVICE.
  2. Start the runner service with the normal Service Control Manager environment, where the process current directory is C:\Windows\System32.
  3. Trigger a runner self-update while an update.finished marker exists in that inherited current directory and is not deletable by the runner service identity. This can occur when multiple runner services with different identities share the same inherited System32 working directory.
  4. The runner downloads and validates the official update package, extracts the candidate version, and generates _work\_update.cmd.
  5. The update then fails when SelfUpdater resolves the relative marker path against the process current directory.

The underlying issue can also be reproduced without System32 by starting the listener with a protected current directory that differs from the runner root and contains a marker the listener cannot delete.

Expected behavior

The update marker and update script process should be scoped to the individual runner root, independently of the host process current directory:

<RunnerRoot>\update.finished

The generated update script should run with <RunnerRoot> as its WorkingDirectory.

Actual behavior

The marker path and generated update script inherit the Windows service process current directory. In the observed service configuration, this resolves to C:\Windows\System32 and causes multiple runner services to share the same marker location.

Runner Version and Platform

  • Update attempted: 2.334.0 to 2.336.0
  • The affected logic is also present in runner 2.336.0 and current main commit 99e01149303b194e08778cdc9c03fa2703d03fb2b
  • OS: Windows 10 Pro 22H2, build 19045.6332, x64
  • Hosting mode: Windows service, automatic delayed start
  • Service identity: NT AUTHORITY\NETWORK SERVICE
  • Runner installation path: local non-system volume; exact repository and runner names redacted

What's not working?

The listener diagnostic log contains:

System.UnauthorizedAccessException:
Access to the path 'C:\Windows\system32\update.finished' is denied.

Before this exception, the diagnostic log confirms that the runner successfully:

  • detected the 2.336.0 update;
  • downloaded the official runner package;
  • validated the package hash;
  • extracted the candidate version;
  • copied the candidate files; and
  • generated _work\_update.cmd.

The relevant implementation currently uses a relative marker path and does not set the update process working directory:

  • src/Runner.Listener/SelfUpdater.cs
  • src/Runner.Listener/SelfUpdaterV2.cs
  • src/Misc/layoutbin/update.cmd.template

SelfUpdater.cs and SelfUpdaterV2.cs delete:

string flagFile = "update.finished";
IOUtil.DeleteFile(flagFile);

The Windows update template creates the same relative marker:

type nul > update.finished

The process used to launch the generated update script does not explicitly set WorkingDirectory.

A proposed fix is to resolve the marker from WellKnownDirectory.Root and set the generated update process working directory to that same runner root:

string runnerRoot = HostContext.GetDirectory(WellKnownDirectory.Root);
string flagFile = Path.Combine(runnerRoot, "update.finished");

IOUtil.DeleteFile(flagFile);

Process invokeScript = new();
invokeScript.StartInfo.WorkingDirectory = runnerRoot;

A patch implementing the fix for both update flows has been prepared against current main.

  • Patch SHA-256: F4FF72115317745806A93D3BAC4883944BFAD3CC6B6804138161EDBE6C291155
  • git diff --check: passed
  • Patch application check against current main: passed
  • Targeted build for the affected runner projects: passed with 0 warnings and 0 errors
  • L0 regression tests: 2 passed, 0 failed

The two L0 tests separately exercise the production preparation methods in SelfUpdater and SelfUpdaterV2. They cover:

  • a runner root containing spaces;
  • deletion of the marker from the runner root;
  • WorkingDirectory set to the runner root; and
  • preservation of quoting for an update script path containing spaces.

The patch can be provided if maintainers would like to review or adopt it. No custom runner binary is being used in production.

Job Log Output

Not applicable. The failure occurs in the runner listener self-update flow, outside an individual workflow step.

Runner and Worker's Diagnostic Logs

Relevant redacted listener diagnostic excerpt:

Runner update in progress, do not shutdown runner.
Downloading 2.336.0 runner
Download latest runner and unzip into runner root.
Waiting for current job finish running.
All running job has exited.
Generate and execute update script.
System.UnauthorizedAccessException:
Access to the path 'C:\Windows\system32\update.finished' is denied.
Runner update process finished.

Repository names, runner names, machine names, tokens, credentials, and exact local installation paths have been removed from this report.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions