From bbb0da025b7cf131a180e794d431ed05bc8c3e8d Mon Sep 17 00:00:00 2001 From: "dd-octo-sts[bot]" <200755185+dd-octo-sts[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 17:46:57 +0000 Subject: [PATCH] feat(windows): deliver AI Usage host as EUDM-gated fleet extension (#53406) ### What does this PR do? MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the Windows **AI Usage Chrome Native Messaging host + desktop monitor** out of the MSI and into a new **`eudm` fleet installer extension** (modeled on `ddot`), installed **only when End User Device Monitoring (EUDM) is enabled**. This is the long-term follow-up to the interim MSI-level EUDM gate (#53289). **Gating** (`installAgentExtensions`): appends the `eudm` extension on Windows when EUDM is signaled via `DD_INFRASTRUCTURE_MODE=end_user_device` **or** `infrastructure_mode: end_user_device` already in `datadog.yaml` (covers upgrades that don't re-pass the env var). The extension is pulled from the `datadog-agent` OCI image's `ai-usage` layer by the post-install hook (`RunPostInstallHook` → `installAgentExtensions`), which already runs on every MSI install/upgrade. **New extension hooks** (`datadog_agent_eudm_windows.go`) reproduce what the MSI custom actions used to do: - copy the native host binary into the Agent's `bin\agent` (user-executable via Program Files ACL inheritance), - write the Chrome host manifest JSON (resolved `chrome_extension_id`, fallback) + the two HKLM `NativeMessagingHosts` registry entries, - register + start the logon-triggered `Datadog AI Usage Agent` scheduled task (`schtasks`, UTF‑16LE XML). - `preRemove` tears all of it down. **MSI strip + migration cleanup:** - Removed the AI Usage binary `File`, both Chrome regkeys, the `WriteConfig` yaml/manifest generation, and the `ConfigureAiUsageMonitorDesktopMonitor` custom action. - Forward `DD_INFRASTRUCTURE_MODE` into `RunPostInstallHook`. **Packaging:** new `//cmd/ai_prompt_logger:install-extension` Bazel target (flat layer layout), removed the omnibus MSI-payload staging, and added `msi.fetch-artifacts --ai-usage` / `msi.package-oci --eudm` (`--extension ai-usage=`). ### Motivation https://datadoghq.atlassian.net/browse/WINA-2867 — AI Usage monitoring should be gated behind EUDM rather than installed unconditionally. The ticket calls for this to become a fleet package (this PR), superseding the interim MSI gate. ### Describe how you validated your changes - Added e2e tests that verify extension installation. - unit tests - manually tested to verify chrome component still works and install causes no issues ### Additional Notes if a machine has old MSI-created AI Usage artifacts but does not install the ai-usage extension, extension hooks never run, so those old artifacts would not be cleaned by this removal. Co-authored-by: jack.phillips (cherry picked from commit 514a2e1bc9677fd89682ed6675df5e3310182755) ___ Co-authored-by: Jack Phillips --- .../windows/build/package_build/windows.yml | 67 ++ .gitlab/windows/build/packaging/oci.yml | 15 + .../test/e2e_install_packages/windows.yml | 30 + cmd/ai_prompt_logger/BUILD.bazel | 21 + cmd/ai_prompt_logger/README.md | 22 +- omnibus/config/projects/agent.rb | 2 - omnibus/config/projects/eudm.rb | 80 +++ omnibus/config/software/ai-usage-agent.rb | 33 + omnibus/config/software/datadog-agent.rb | 9 - pkg/fleet/installer/packages/BUILD.bazel | 3 + .../packages/datadog_agent_eudm_windows.go | 593 ++++++++++++++++++ .../datadog_agent_eudm_windows_test.go | 89 +++ .../packages/datadog_agent_extensions.go | 90 ++- .../packages/datadog_agent_extensions_test.go | 32 + .../packages/datadog_agent_windows.go | 6 + ...eudm-fleet-extension-59350ff4e4c802b0.yaml | 8 + tasks/msi.py | 52 +- .../windows/eudm_msi_install_test.go | 181 ++++++ .../AgentCustomActions/CustomAction.cs | 12 - .../WriteConfigUnitTests.cs | 154 ----- .../AiUsageDesktopMonitorCustomAction.cs | 146 ----- .../CustomActions/ConfigCustomActions.cs | 174 ----- .../CustomActions/Constants.cs | 2 - .../CustomActions/CustomActions.csproj | 1 - .../InstallerHooksCustomAction.cs | 2 + .../WixSetup/Datadog Agent/AgentBinaries.cs | 5 - .../Datadog Agent/AgentCustomActions.cs | 35 +- .../WixSetup/Datadog Agent/AgentInstaller.cs | 20 - 28 files changed, 1304 insertions(+), 580 deletions(-) create mode 100644 omnibus/config/projects/eudm.rb create mode 100644 omnibus/config/software/ai-usage-agent.rb create mode 100644 pkg/fleet/installer/packages/datadog_agent_eudm_windows.go create mode 100644 pkg/fleet/installer/packages/datadog_agent_eudm_windows_test.go create mode 100644 releasenotes/notes/ai-usage-eudm-fleet-extension-59350ff4e4c802b0.yaml create mode 100644 test/new-e2e/tests/installer/windows/eudm_msi_install_test.go delete mode 100644 tools/windows/DatadogAgentInstaller/CustomActions/AiUsageDesktopMonitorCustomAction.cs diff --git a/.gitlab/windows/build/package_build/windows.yml b/.gitlab/windows/build/package_build/windows.yml index 117f5ba82711..5aa730d9acfa 100644 --- a/.gitlab/windows/build/package_build/windows.yml +++ b/.gitlab/windows/build/package_build/windows.yml @@ -156,3 +156,70 @@ windows_zip_ddot_x64: extends: .windows_zip_base variables: OMNIBUS_TARGET: ddot + +# Like .windows_zip_base, but for omnibus targets whose build invokes Bazel (e.g. the eudm +# extension's ai-usage Rust native host). Uses the Bazel-cache docker wrapper (as .windows_msi_base does) instead of a +# plain `docker run`, so `bazel run` has the cache/toolchain available inside the container. +.windows_zip_bazel_base: + stage: package_build + rules: + - !reference [.except_mergequeue] + - when: on_success + extends: [.windows_docker_default, .bazel:defs:cache:windows] + needs: ["go_deps"] + variables: + ARCH: "x64" + script: + - $ErrorActionPreference = "Stop" + - !reference [.sanitize_goproxy_windows] + - if (Test-Path omnibus\pkg) { remove-item -recurse -force omnibus\pkg } + - mkdir omnibus\pkg + - > + .\tools\ci\docker-run-with-bazel-cache.ps1 + -m 24576M + -v "$(Get-Location):c:\mnt" + -e DDA_FEATURE_FLAGS_CI_SSM_KEY_WINDOWS="${DDA_FEATURE_FLAGS_CI_SSM_KEY_WINDOWS}" + -e GITLAB_CI + -e CI_JOB_ID + -e CI_PIPELINE_ID + -e CI_PROJECT_NAME + -e CI_COMMIT_BRANCH + -e CI_JOB_NAME_SLUG + -e CI_COMMIT_REF_NAME + -e OMNIBUS_TARGET + -e WINDOWS_BUILDER=true + -e GOMODCACHE="c:\modcache" + -e GOPROXY + -e GONOSUMDB + -e AWS_NETWORKING=true + -e SIGN_WINDOWS_DD_WCS=true + -e WINDOWS_SIGNING_CERT + -e WINDOWS_SIGNING_CONFIG + -e TARGET_ARCH="$ARCH" + -e BUCKET_BRANCH + -e S3_OMNIBUS_CACHE_BUCKET + -e S3_OMNIBUS_GIT_CACHE_BUCKET + -e BUNDLE_MIRROR__RUBYGEMS__ORG + -e PIP_INDEX_URL + -e AGENT_API_KEY_ORG2 + -e OMNIBUS_GIT_CACHE_DIR=${Env:TEMP}/${CI_PIPELINE_ID}/omnibus-git-cache + -e AGENT_FLAVOR + -e OMNIBUS_RUBY_VERSION + -e PYTHONUTF8=1 + -e DEPLOY_AGENT + -e CI_JOB_TOKEN + -e CI_IDENTITIES_GITLAB_ID_TOKEN + ${WINBUILDIMAGE} + powershell -C "c:\mnt\tasks\winbuildscripts\Build-OmnibusTarget.ps1 -BuildOutOfSource 1 -InstallDeps 1 -CheckGoVersion 1" + - If ($lastExitCode -ne "0") { throw "Previous command returned $lastExitCode" } + - get-childitem omnibus\pkg\pipeline-$CI_PIPELINE_ID + - !reference [.upload_sbom_artifacts_windows] + artifacts: + expire_in: 2 weeks + paths: + - omnibus/pkg/pipeline-$CI_PIPELINE_ID + +windows_zip_eudm_x64: + extends: .windows_zip_bazel_base + variables: + OMNIBUS_TARGET: eudm diff --git a/.gitlab/windows/build/packaging/oci.yml b/.gitlab/windows/build/packaging/oci.yml index 4aaeeaadb10e..635ae88ad857 100644 --- a/.gitlab/windows/build/packaging/oci.yml +++ b/.gitlab/windows/build/packaging/oci.yml @@ -60,6 +60,8 @@ agent_win_oci: artifacts: true - job: "windows_zip_ddot_x64" artifacts: true + - job: "windows_zip_eudm_x64" + artifacts: true variables: OCI_PRODUCT: "datadog-agent" WIN_SOURCE_TYPE: "msi" @@ -95,6 +97,19 @@ agent_win_oci: EXTRA_FLAGS="${EXTRA_FLAGS} --extension ddot=${DDOT_EXTENSION_DIR}" fi + - | + EUDM_ARTIFACT=$(ls -1 "${PIPE_DIR}"/datadog-agent-eudm-*x86_64.zip 2>/dev/null || true) + if [ -n "$EUDM_ARTIFACT" ]; then + EUDM_INPUT_DIR="$(mktemp -d)" + EUDM_EXTENSION_DIR="$(mktemp -d)" + echo "Extracting EUDM extension to ${EUDM_INPUT_DIR}" + unzip -q "${EUDM_ARTIFACT}" -d "${EUDM_INPUT_DIR}" + + # Copy entire EUDM extension structure (flat: AI Usage host binary + example config) to extension directory + cp -r "${EUDM_INPUT_DIR}"/* "${EUDM_EXTENSION_DIR}"/ + + EXTRA_FLAGS="${EXTRA_FLAGS} --extension eudm=${EUDM_EXTENSION_DIR}" + fi - | INSTALLER_BIN=$(ls -1 "${PIPE_DIR}"/datadog-installer-*-x86_64.exe 2>/dev/null | head -1) if [ -n "$INSTALLER_BIN" ]; then diff --git a/.gitlab/windows/test/e2e_install_packages/windows.yml b/.gitlab/windows/test/e2e_install_packages/windows.yml index 9261c7f09221..b975043155b8 100644 --- a/.gitlab/windows/test/e2e_install_packages/windows.yml +++ b/.gitlab/windows/test/e2e_install_packages/windows.yml @@ -169,6 +169,36 @@ new-e2e-windows-ddot-package-a7-x86_64: - !reference [.manual] timeout: 45m +new-e2e-windows-eudm-package-a7-x86_64: + stage: e2e_install_packages + extends: + - .new_e2e_template + - .new-e2e_agent_a7 + variables: + TARGETS: ./tests/installer/windows + EXTRA_PARAMS: --run "TestEUDMExtensionViaMSI" + TEAM: windows-products + needs: + - !reference [.needs_new_e2e_template] + - deploy_windows_testing-a7 + - deploy_installer_oci + - deploy_agent_oci + - qa_installer_script_windows + before_script: + - CURRENT_AGENT_ASSERT_VERSION=$(dda inv agent.version) || exit $?; export CURRENT_AGENT_ASSERT_VERSION + - CURRENT_AGENT_ASSERT_PACKAGE_VERSION=$(dda inv agent.version --url-safe)-1 || exit $?; export CURRENT_AGENT_ASSERT_PACKAGE_VERSION + - !reference [.new_e2e_template, before_script] + parallel: + matrix: + - EXTRA_PARAMS: --run "TestEUDMExtensionViaMSI$/TestInstallAndUninstallEUDMExtension$" + - EXTRA_PARAMS: --run "TestEUDMExtensionViaMSI$/TestUpgradeEnablesEUDMExtension$" + - EXTRA_PARAMS: --run "TestEUDMExtensionViaMSI$/TestInstallWithoutEUDMSkipsExtension$" + rules: + - !reference [.on_deploy] + - !reference [.on_e2e_or_windows_installer_changes] + - !reference [.manual] + timeout: 45m + new-e2e-installer-windows: extends: .new_e2e_template rules: diff --git a/cmd/ai_prompt_logger/BUILD.bazel b/cmd/ai_prompt_logger/BUILD.bazel index 9edd78416d22..9f39ddc81028 100644 --- a/cmd/ai_prompt_logger/BUILD.bazel +++ b/cmd/ai_prompt_logger/BUILD.bazel @@ -111,3 +111,24 @@ pkg_install( }), target_compatible_with = _MACOS_OR_WINDOWS, ) + +# Windows-only flat layout for the "eudm" fleet installer extension layer. The binary and +# example config are placed at the layer root, matching the paths that the extension hook expects +# under ext/eudm/ (see pkg/fleet/installer/packages/datadog_agent_eudm_windows.go). The +# Chrome manifest and registry entries are created at install time by the extension hook, so only +# the binary and example config ship in the layer. +pkg_files( + name = "ai_usage_extension_files", + srcs = [ + "ai_usage_native_host.yaml.example", + ":ai-usage-agent-native-host", + ], + target_compatible_with = ["@platforms//os:windows"], + visibility = ["//packages:__subpackages__"], +) + +pkg_install( + name = "install-extension", + srcs = [":ai_usage_extension_files"], + target_compatible_with = ["@platforms//os:windows"], +) diff --git a/cmd/ai_prompt_logger/README.md b/cmd/ai_prompt_logger/README.md index 42c2d18c67af..306991eb83a3 100644 --- a/cmd/ai_prompt_logger/README.md +++ b/cmd/ai_prompt_logger/README.md @@ -2,7 +2,7 @@ Chrome **Native Messaging** host that forwards **AI usage** events to the **local Datadog Agent** via the trace receiver **EVP proxy** and the dedicated AI usage track, not directly to Datadog with a customer API key. -Chrome registration for **macOS** is done by the **Datadog Agent** package (`postinst` + per-user manifests). On **Windows**, the MSI installs a machine-wide Chrome Native Messaging Host registration under HKLM — see the **Windows install** section below. +Chrome registration for **macOS** is done by the **Datadog Agent** package (`postinst` + per-user manifests). On **Windows**, registration is owned by the **ai-usage** fleet installer extension when End User Device Monitoring is enabled — see the **Windows install** section below. The same binary can also run as a standalone desktop monitor with `--desktop-monitor`. In that mode it is a separate long-lived user-session process and does not use Chrome's native messaging protocol. @@ -38,7 +38,7 @@ Settings live in **`ai_usage_native_host.yaml`** (see **`ai_usage_native_host.ya On a **packaged macOS** agent, Chrome is pointed at **`embedded/bin/run_ai_usage_native_host.sh`**, which **`exec`s** the binary with **`--config=$install_root/etc/ai_usage_native_host.yaml`**. On first config creation, the installer generates `trace_agent_url` from the Agent's `apm_config.receiver_port` in `datadog.yaml`. -On a **packaged Windows** agent (MSI), Chrome is pointed directly at **`bin\agent\ai-usage-agent-native-host.exe`** through a machine-wide HKLM Native Messaging Host registration; no shell wrapper is used. +On a **packaged Windows** agent with End User Device Monitoring enabled, the **ai-usage** fleet installer extension points Chrome directly at the **`ai-usage-agent-native-host.exe`** shipped in the extension layer through a machine-wide HKLM Native Messaging Host registration; no shell wrapper is used. EVP / Agent URL behaviour (defaults): @@ -117,27 +117,29 @@ Response: { "type": "SEND_USAGE_EVENT_RESULT", "success": true } ``` -## Windows install (MSI) +## Windows install (EUDM fleet extension) -The Datadog Agent Windows MSI ships the native host under the Agent install directory and registers it with Chrome machine-wide: +On Windows, the native host is delivered by the `ai-usage` fleet installer extension. The extension is installed when End User Device Monitoring is enabled (`DD_INFRASTRUCTURE_MODE=end_user_device`) and skipped otherwise. The host binary and its Chrome manifest ship inside the extension layer and run in place from there — nothing is copied into Program Files. The install hook generates the manifest, writes the active config, and registers the host with Chrome machine-wide: | Path (default install) | Contents | |---|---| -| `C:\Program Files\Datadog\Datadog Agent\bin\agent\ai-usage-agent-native-host.exe` | The host binary (signed). | -| `C:\Program Files\Datadog\Datadog Agent\bin\agent\dist\com.datadoghq.ai_usage_agent.native_host.json` | Chrome Native Messaging Host manifest generated by the MSI. | +| `C:\ProgramData\Datadog\Installer\packages\datadog-agent\\ext\eudm\ai-usage-agent-native-host.exe` | The host binary (signed), shipped in the extension layer and run in place. | +| `C:\ProgramData\Datadog\Installer\packages\datadog-agent\\ext\eudm\com.datadoghq.ai_usage_agent.native_host.json` | Chrome Native Messaging Host manifest generated by the extension install hook. | | `C:\ProgramData\Datadog\ai_usage_native_host.yaml.example` | Config example. | -| `C:\ProgramData\Datadog\ai_usage_native_host.yaml` | Active config generated by the MSI if missing. | +| `C:\ProgramData\Datadog\ai_usage_native_host.yaml` | Active config generated by the extension install hook if missing. | -The MSI writes machine-wide registry keys so every Chrome user on the machine can discover the same native host: +The extension install hook writes machine-wide registry keys so every Chrome user on the machine can discover the same native host: ``` HKLM\SOFTWARE\Google\Chrome\NativeMessagingHosts\com.datadoghq.ai_usage_agent.native_host HKLM\SOFTWARE\WOW6432Node\Google\Chrome\NativeMessagingHosts\com.datadoghq.ai_usage_agent.native_host ``` -The default value for both keys points to the manifest under `bin\agent\dist`. The manifest's `path` field points to the bundled host executable under `bin\agent`, and `allowed_origins` uses the installer default Chrome extension ID (`gkmbhgbippkmmmidcikijiblbagbjgjj`). The active config's `trace_agent_url` is generated from the Agent's `apm_config.receiver_port` in `datadog.yaml`. +The default value for both keys points to the manifest in the extension layer (the `ext\eudm` directory above). The manifest's `path` field points to the host executable in that same directory, and `allowed_origins` uses the installer default Chrome extension ID (`gkmbhgbippkmmmidcikijiblbagbjgjj`). The active config's `trace_agent_url` is generated from the Agent's `apm_config.receiver_port` in `datadog.yaml`. -The MSI registers a Task Scheduler logon task named `Datadog AI Usage Agent`. The task launches the same bundled host executable with `--desktop-monitor --config "C:\ProgramData\Datadog\ai_usage_native_host.yaml"` in the interactive user session. Chrome native messaging registration remains separate and continues to launch the host without `--desktop-monitor`. When the `DatadogAgent` service is stopped, the desktop monitor remains resident but skips desktop scans until SCM reports the service running again. +The extension install hook registers a Task Scheduler logon task named `Datadog AI Usage Agent`. The task launches the same host executable from the extension layer with `--desktop-monitor --config "C:\ProgramData\Datadog\ai_usage_native_host.yaml"` in the interactive user session. Chrome native messaging registration remains separate and continues to launch the host without `--desktop-monitor`. When the `DatadogAgent` service is stopped, the desktop monitor remains resident but skips desktop scans until SCM reports the service running again. + +The extension remove hook deletes the scheduled task and Chrome registry keys and stops the running host. The host binary and manifest live in the extension layer and are removed with the extension itself. It preserves the user-editable `C:\ProgramData\Datadog\ai_usage_native_host.yaml`. ## macOS install (DMG) diff --git a/omnibus/config/projects/agent.rb b/omnibus/config/projects/agent.rb index 784234ef1961..f04bea764505 100644 --- a/omnibus/config/projects/agent.rb +++ b/omnibus/config/projects/agent.rb @@ -342,7 +342,6 @@ windows_symbol_stripping_file "#{install_dir}\\bin\\agent\\dd-procmgrd.exe" windows_symbol_stripping_file "#{install_dir}\\bin\\agent\\dd-procmgr.exe" windows_symbol_stripping_file "#{install_dir}\\bin\\agent\\agent-data-plane.exe" - windows_symbol_stripping_file "#{install_dir}\\bin\\agent\\ai-usage-agent-native-host.exe" if windows_signing_enabled? # Sign additional binaries from here. @@ -370,7 +369,6 @@ "#{install_dir}\\bin\\agent\\dd-procmgrd.exe", "#{install_dir}\\bin\\agent\\dd-procmgr.exe", "#{install_dir}\\bin\\agent\\agent-data-plane.exe", - "#{install_dir}\\bin\\agent\\ai-usage-agent-native-host.exe", ] BINARIES_TO_SIGN.each do |bin| diff --git a/omnibus/config/projects/eudm.rb b/omnibus/config/projects/eudm.rb new file mode 100644 index 000000000000..4b5721042371 --- /dev/null +++ b/omnibus/config/projects/eudm.rb @@ -0,0 +1,80 @@ +# Unless explicitly stated otherwise all files in this repository are licensed +# under the Apache License Version 2.0. +# This product includes software developed at Datadog (https:#www.datadoghq.com/). +# Copyright 2016-present Datadog, Inc. +require "./lib/ostools.rb" +require "./lib/project_helpers.rb" + +# The "eudm" (End User Device Monitoring) fleet installer extension layer for the datadog-agent +# OCI package (Windows only). It currently carries the AI Usage native host feature; this project +# builds + signs the native host binary and produces datadog-agent-eudm--1-x86_64.zip, +# which the agent_win_oci job passes to `datadog-package create --extension eudm=`. +# Mirrors omnibus/config/projects/ddot.rb. + +name 'eudm' +package_name 'datadog-agent-eudm' + +license "Apache-2.0" +license_file "../LICENSE" + +third_party_licenses "../LICENSE-3rdparty.csv" + +homepage 'http://www.datadoghq.com' + +if ENV.has_key?("OMNIBUS_WORKERS_OVERRIDE") + COMPRESSION_THREADS = ENV["OMNIBUS_WORKERS_OVERRIDE"].to_i +else + COMPRESSION_THREADS = 1 +end + +if ENV.has_key?("OMNIBUS_GIT_CACHE_DIR") + Omnibus::Config.use_git_caching true + Omnibus::Config.git_cache_dir ENV["OMNIBUS_GIT_CACHE_DIR"] +end + +# Dedicated install_dir so the produced ZIP unzips to the flat ext/eudm/ layout the extension +# hook expects (binary + example config at the root). Must not contain spaces (Omnibus does not +# quote the Git commands it launches). Matches EUDM_ARTIFACT_DIR in tasks/msi.py. +INSTALL_DIR = 'C:/opt/datadog-agent-eudm' +install_dir INSTALL_DIR + +third_party_licenses_path "LICENSES-eudm" +license_file_path "LICENSE-eudm" +json_manifest_path File.join(install_dir, "version-manifest.eudm.json") +text_manifest_path File.join(install_dir, "version-manifest.eudm.txt") + +# build_version is computed by an invoke command/function. +# We can't call it directly from there, we pass it through the environment instead. +build_version ENV['PACKAGE_VERSION'] + +build_iteration 1 + +description 'Datadog AI Usage native messaging host' + +maintainer 'Datadog Packages ' + +# ------------------------------------ +# Dependencies +# ------------------------------------ +dependency 'ai-usage-agent' + +# the stripper will drop the symbols in a `.debug` folder in the installdir +# we want to make sure that directory is not in the main build, while present +# in the debug package. +strip_build true +debug_path ".debug" # the strip symbols will be in here + +if windows_target? + windows_symbol_stripping_file "#{install_dir}\\ai-usage-agent-native-host.exe" + sign_file "#{install_dir}\\ai-usage-agent-native-host.exe" +end + +# ------------------------------------ +# Packaging +# ------------------------------------ + +# Windows only: skip the MSI packager and rely on the default Windows ZIP packager, which emits +# datadog-agent-eudm--1-x86_64.zip from the contents of install_dir. +package :msi do + skip_packager true +end diff --git a/omnibus/config/software/ai-usage-agent.rb b/omnibus/config/software/ai-usage-agent.rb new file mode 100644 index 000000000000..74ab72fabb6f --- /dev/null +++ b/omnibus/config/software/ai-usage-agent.rb @@ -0,0 +1,33 @@ +# Unless explicitly stated otherwise all files in this repository are licensed +# under the Apache License Version 2.0. +# This product includes software developed at Datadog (https:#www.datadoghq.com/). +# Copyright 2016-present Datadog, Inc. + +require './lib/ostools.rb' + +name 'ai-usage-agent' + +source path: '..', + options: { + exclude: [ + "**/.cache/**/*", + "**/testdata/**/*", + ], + } +relative_path 'src/github.com/DataDog/datadog-agent' + +always_build true + +build do + license :project_license + + # The AI Usage Chrome native messaging host is a Rust binary built by Bazel. It ships inside the + # "eudm" fleet installer extension layer (Windows only), so we stage the flat layout the + # extension hook expects — the binary and the example config at the install_dir root: + # /ai-usage-agent-native-host.exe + # /ai_usage_native_host.yaml.example + # (see pkg/fleet/installer/packages/datadog_agent_eudm_windows.go). + if windows_target? + command "bazel run -- //cmd/ai_prompt_logger:install-extension --destdir=#{install_dir}", :live_stream => Omnibus.logger.live_stream(:info) + end +end diff --git a/omnibus/config/software/datadog-agent.rb b/omnibus/config/software/datadog-agent.rb index 092c052376de..0b459ecd21a6 100644 --- a/omnibus/config/software/datadog-agent.rb +++ b/omnibus/config/software/datadog-agent.rb @@ -294,15 +294,6 @@ copy "#{systray_build_dir}/agent.png", "#{app_temp_dir}/MacOS/" end - if windows_target? - # AI usage Chrome native messaging host (Rust). Mirrors the macOS osx_target? branch above: - # the Bazel target installs the .exe into bin/agent (Windows convention; see - # //pkg/procmgr/rust:install for the same Linux-vs-Windows prefix split). The final Chrome - # Native Messaging Host manifest is staged under bin/agent/dist so the MSI owns the file - # during rollback/uninstall. The MSI custom action rewrites it with the final installation path. - command "bazel run #{bazel_flags} -- //cmd/ai_prompt_logger:install --destdir=#{install_dir}", :env => env, :live_stream => Omnibus.logger.live_stream(:info) - end - # APM Hands Off config file if linux_target? copy 'pkg/config/example/application_monitoring.yaml.example', "#{conf_dir}/application_monitoring.yaml.example" diff --git a/pkg/fleet/installer/packages/BUILD.bazel b/pkg/fleet/installer/packages/BUILD.bazel index 494f0d725233..e180f5892433 100644 --- a/pkg/fleet/installer/packages/BUILD.bazel +++ b/pkg/fleet/installer/packages/BUILD.bazel @@ -8,6 +8,7 @@ go_library( "apm_library_dotnet_windows.go", "datadog_agent_ddot_linux.go", "datadog_agent_ddot_windows.go", + "datadog_agent_eudm_windows.go", "datadog_agent_extensions.go", "datadog_agent_linux.go", "datadog_agent_nix.go", @@ -77,6 +78,7 @@ go_library( "@org_golang_x_sys//windows/registry", "@org_golang_x_sys//windows/svc", "@org_golang_x_sys//windows/svc/mgr", + "@org_golang_x_text//encoding/unicode", ], "//conditions:default": [], }), @@ -86,6 +88,7 @@ go_test( name = "packages_test", srcs = [ "apm_inject_windows_test.go", + "datadog_agent_eudm_windows_test.go", "datadog_agent_extensions_test.go", "otel_config_common_test.go", ], diff --git a/pkg/fleet/installer/packages/datadog_agent_eudm_windows.go b/pkg/fleet/installer/packages/datadog_agent_eudm_windows.go new file mode 100644 index 000000000000..e18ccffe37c2 --- /dev/null +++ b/pkg/fleet/installer/packages/datadog_agent_eudm_windows.go @@ -0,0 +1,593 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +//go:build windows + +package packages + +import ( + "context" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + "unsafe" + + "go.yaml.in/yaml/v2" + + "github.com/DataDog/datadog-agent/pkg/fleet/installer/paths" + "github.com/DataDog/datadog-agent/pkg/util/log" + + "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" + textunicode "golang.org/x/text/encoding/unicode" +) + +// End User Device Monitoring (eudm) extension. +// Features: +// - AI Usage Chrome Native Messaging host / desktop monitor +const ( + // aiUsageNativeHostName is the Chrome native messaging host name. It is used for the + // HKLM registry key, the manifest filename, and the manifest "name" field. + aiUsageNativeHostName = "com.datadoghq.ai_usage_agent.native_host" + // aiUsageObsoleteNativeHostName is a previous host name whose manifest is cleaned up. + aiUsageObsoleteNativeHostName = "com.datadoghq.ai_prompt_logger.native_host" + // aiUsageFallbackChromeExtensionID is used when no chrome_extension_id override is + // configured in ai_usage_native_host.yaml(.example). + aiUsageFallbackChromeExtensionID = "gkmbhgbippkmmmidcikijiblbagbjgjj" + + aiUsageBinaryName = "ai-usage-agent-native-host.exe" + aiUsageConfigName = "ai_usage_native_host.yaml" + + aiUsageTaskName = "Datadog AI Usage Agent" + aiUsageTaskDescription = "Starts the Datadog AI Usage Agent desktop monitor in the interactive user session." + // aiUsageUsersGroupSID is BUILTIN\Users, the principal the desktop monitor scheduled task + // runs as (in the interactive user session at LeastPrivilege). + aiUsageUsersGroupSID = "S-1-5-32-545" + + aiUsageDefaultReceiverPort = 8126 + + aiUsageHostProcessTerminationWait = 5 * time.Second + aiUsageHostProcessTerminationStatus = 1 + aiUsageTaskXMLNamespace = "http://schemas.microsoft.com/windows/2004/02/mit/task" + + // aiUsageChromeRegKeyPath and aiUsageChromeRegKeyPathWow are the machine-wide Chrome + // NativeMessagingHosts registration keys. Chrome reads the (default) value to find the + // host manifest JSON. + aiUsageChromeRegKeyPath = `Software\Google\Chrome\NativeMessagingHosts\` + aiUsageNativeHostName + aiUsageChromeRegKeyPathWow = `Software\WOW6432Node\Google\Chrome\NativeMessagingHosts\` + aiUsageNativeHostName +) + +// aiUsageChromeRegKeyPaths are the two machine-wide Chrome NativeMessagingHosts registration +// keys (native and WOW6432Node) that are created, read, and deleted together. +var aiUsageChromeRegKeyPaths = []string{aiUsageChromeRegKeyPath, aiUsageChromeRegKeyPathWow} + +// aiUsageTraceAgentURLRe matches the trace_agent_url line (commented or not) in the +// ai_usage_native_host.yaml.example template. +var aiUsageTraceAgentURLRe = regexp.MustCompile(`(?m)^[ #]*trace_agent_url:.*$`) + +// aiUsageNativeHostConfig is the subset of ai_usage_native_host.yaml we need to read. +type aiUsageNativeHostConfig struct { + ChromeExtensionID string `yaml:"chrome_extension_id"` +} + +// aiUsageDatadogConfig is the subset of datadog.yaml we need to read. +type aiUsageDatadogConfig struct { + APMConfig struct { + ReceiverPort *int `yaml:"receiver_port"` + } `yaml:"apm_config"` +} + +// aiUsageExtensionPath resolves the versioned extension directory. ctx.PackagePath may be a +// "stable"/"experiment" symlink; resolving it keeps the scheduled-task command and Chrome +// manifest exe path valid after the symlink is repointed on promote/stop-experiment. +func aiUsageExtensionPath(ctx HookContext) string { + packagePath := ctx.PackagePath + if resolved, err := filepath.EvalSymlinks(ctx.PackagePath); err == nil { + packagePath = resolved + } + return filepath.Join(packagePath, "ext", ctx.Extension) +} + +// preInstallEUDMExtension quiesces the AI Usage host before the extension layer is extracted: it +// clears the machine-wide Chrome registration (so Chrome does not spawn the old host while the new +// layer is being laid down), removes the stale scheduled task, and stops any running host +// processes. installSingle always runs this immediately before postInstallEUDMExtension. +func preInstallEUDMExtension(ctx HookContext) error { + deleteAIUsageChromeRegistry() + removeAIUsageScheduledTask(ctx.Context) + stopAIUsageHostProcesses(ctx.Context) + return nil +} + +// postInstallEUDMExtension sets up the eudm extension's AI Usage native host after the layer is extracted. +func postInstallEUDMExtension(ctx HookContext) error { + if paths.DatadogDataDir == "" { + return errors.New("cannot install AI Usage extension: Agent data directory is unknown") + } + extensionPath := aiUsageExtensionPath(ctx) + + // The native host runs in place from the extracted extension layer. That directory is world + // read/execute, so Chrome and the desktop-monitor task (which launch it as the interactive + // browser user) can execute it without copying it elsewhere or changing ACLs. The generated + // manifest is written into the same layer dir. postInstall re-runs on every upgrade, repointing + // the manifest/registration/task at the new layer, so the versioned path stays valid. + binaryPath := filepath.Join(extensionPath, aiUsageBinaryName) + if _, err := os.Stat(binaryPath); err != nil { + return fmt.Errorf("AI Usage native host binary not found at %s: %w", binaryPath, err) + } + manifestPath := filepath.Join(extensionPath, aiUsageNativeHostName+".json") + + // The Chrome registration was already cleared in preInstallEUDMExtension (which installSingle + // runs immediately before this hook); it is rewritten in step 3 once the manifest is in place. + success := false + defer func() { + if success { + return + } + // The hook failed. installSingle removes the extracted extension directory (binary + + // manifest) on error, so here we only undo the machine-wide state: the Chrome registration + // and the scheduled task. + deleteAIUsageChromeRegistry() + removeAIUsageScheduledTask(ctx.Context) + }() + + // 1) Generate ai_usage_native_host.yaml in ProgramData (best effort; preserve an existing file), + // then grant Everyone read/execute on it. C:\ProgramData\Datadog is ACL-restricted, so the + // config would otherwise be unreadable by the interactive browser user that Chrome and the + // desktop-monitor task launch the host as. + configPath := filepath.Join(paths.DatadogDataDir, aiUsageConfigName) + examplePath := filepath.Join(extensionPath, aiUsageConfigName+".example") + if err := writeAIUsageConfig(examplePath, configPath); err != nil { + return fmt.Errorf("failed to write %s: %w", aiUsageConfigName, err) + } + if err := grantAIUsageConfigWorldRead(configPath); err != nil { + log.Warnf("AI Usage: failed to grant read access on %q: %v", configPath, err) + } + // Also lay down the editable sample config next to the active one (mirrors what the MSI used + // to install at C:\ProgramData\Datadog\ai_usage_native_host.yaml.example). Best effort. + if err := copyAIUsageFile(examplePath, configPath+".example", 0o644); err != nil { + log.Warnf("AI Usage: failed to copy example config to %q: %v", configPath+".example", err) + } + + // 2) Write the Chrome host manifest JSON into the extension layer, pointing at the host binary. + extensionID := readAIUsageChromeExtensionID(configPath, examplePath) + if err := writeAIUsageManifest(manifestPath, binaryPath, extensionID); err != nil { + return fmt.Errorf("failed to write AI Usage native messaging manifest: %w", err) + } + + // 3) Register the two Chrome NativeMessagingHosts registry entries pointing at the manifest. + if err := writeAIUsageChromeRegistry(manifestPath); err != nil { + return fmt.Errorf("failed to register Chrome native messaging host: %w", err) + } + + // 4) Register and start the logon-triggered desktop monitor scheduled task. + if err := configureAIUsageScheduledTask(ctx.Context, binaryPath, configPath); err != nil { + return fmt.Errorf("failed to configure AI Usage desktop monitor task: %w", err) + } + + success = true + return nil +} + +// copyAIUsageFile copies src to dst (creating dst's parent directory), replacing any existing file. +func copyAIUsageFile(src, dst string, perm os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { + return err + } + data, err := os.ReadFile(src) + if err != nil { + return err + } + return os.WriteFile(dst, data, perm) +} + +// preRemoveEUDMExtension tears down the AI Usage native host before extension files are removed. +// All steps are best effort so removal is not blocked. +func preRemoveEUDMExtension(ctx HookContext) error { + // Tear down the machine-wide state and stop the running host. The binary and manifest live in + // the extension layer directory, which is removed with the extension itself, so there are no + // extra files to clean up here. The user-editable ai_usage_native_host.yaml under + // ProgramData\Datadog is preserved (mirrors ddot preserving otel-config.yaml). + removeAIUsageScheduledTask(ctx.Context) + deleteAIUsageChromeRegistry() + stopAIUsageHostProcesses(ctx.Context) + return nil +} + +// writeAIUsageConfig renders ai_usage_native_host.yaml from the example template, substituting +// trace_agent_url with the local trace receiver URL. An existing config is preserved. +func writeAIUsageConfig(examplePath, configPath string) error { + if _, err := os.Stat(configPath); err == nil { + log.Debugf("AI Usage: %s already exists, not modifying it", configPath) + return nil + } + example, err := os.ReadFile(examplePath) + if err != nil { + return fmt.Errorf("could not read example config %s: %w", examplePath, err) + } + port := readAIUsageReceiverPort() + rendered := aiUsageTraceAgentURLRe.ReplaceAllString( + string(example), + fmt.Sprintf(`trace_agent_url: "http://127.0.0.1:%d"`, port), + ) + if err := os.WriteFile(configPath, []byte(rendered), 0o644); err != nil { + return fmt.Errorf("could not write %s: %w", configPath, err) + } + return nil +} + +// grantAIUsageConfigWorldRead sets an explicit DACL on the config so Everyone can read/execute it +// while SYSTEM and Administrators keep full control. C:\ProgramData\Datadog is ACL-restricted, so +// the config must be granted read access explicitly for the native host (which Chrome and the +// desktop-monitor task launch as the interactive browser user) to read it. Mirrors the SDDL + +// SetNamedSecurityInfo approach used for the DDOT service (no icacls shell-out). +func grantAIUsageConfigWorldRead(path string) error { + // SY = SYSTEM, BA = BUILTIN\Administrators (GA = full control); WD = Everyone (GR|GX = generic + // read + execute). + const sddl = `D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGX;;;WD)` + sd, err := windows.SecurityDescriptorFromString(sddl) + if err != nil { + return fmt.Errorf("could not parse SDDL: %w", err) + } + dacl, _, err := sd.DACL() + if err != nil { + return fmt.Errorf("could not extract DACL: %w", err) + } + // PROTECTED_DACL_SECURITY_INFORMATION drops the restrictive inherited ACEs so only the explicit + // entries above apply. + return windows.SetNamedSecurityInfo( + path, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION|windows.PROTECTED_DACL_SECURITY_INFORMATION, + nil, nil, dacl, nil, + ) +} + +func deleteAIUsageChromeRegistry() { + for _, path := range aiUsageChromeRegKeyPaths { + if err := registry.DeleteKey(registry.LOCAL_MACHINE, path); err != nil && err != registry.ErrNotExist { + log.Warnf("AI Usage: failed to delete Chrome registry key %q: %v", path, err) + } + } +} + +func stopAIUsageHostProcesses(ctx context.Context) { + pids, err := aiUsageHostProcessIDs() + if err != nil { + log.Warnf("AI Usage: failed to enumerate native host processes before replacement: %v", err) + return + } + for _, pid := range pids { + if ctx != nil { + if err := ctx.Err(); err != nil { + log.Warnf("AI Usage: context canceled before stopping native host process %d: %v", pid, err) + return + } + } + if err := stopAIUsageHostProcess(pid); err != nil { + log.Warnf("AI Usage: failed to stop native host process %d before replacement: %v", pid, err) + } + } +} + +func aiUsageHostProcessIDs() ([]uint32, error) { + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, err + } + defer windows.CloseHandle(snapshot) + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + + var pids []uint32 + err = windows.Process32First(snapshot, &entry) + for err == nil { + if strings.EqualFold(windows.UTF16ToString(entry.ExeFile[:]), aiUsageBinaryName) { + pids = append(pids, entry.ProcessID) + } + err = windows.Process32Next(snapshot, &entry) + } + if err != nil && err != windows.ERROR_NO_MORE_FILES { + return nil, err + } + return pids, nil +} + +func stopAIUsageHostProcess(pid uint32) error { + handle, err := windows.OpenProcess(windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid) + if err != nil { + if errors.Is(err, windows.ERROR_INVALID_PARAMETER) { + return nil + } + return err + } + defer windows.CloseHandle(handle) + + if err := windows.TerminateProcess(handle, aiUsageHostProcessTerminationStatus); err != nil { + return err + } + + wait, err := windows.WaitForSingleObject(handle, uint32(aiUsageHostProcessTerminationWait/time.Millisecond)) + if err != nil { + return err + } + if wait == uint32(windows.WAIT_TIMEOUT) { + return errors.New("timed out waiting for process exit") + } + return nil +} + +// readAIUsageReceiverPort reads apm_config.receiver_port from datadog.yaml, defaulting to 8126. +func readAIUsageReceiverPort() int { + ddYaml := filepath.Join(paths.DatadogDataDir, "datadog.yaml") + data, err := os.ReadFile(ddYaml) + if err != nil { + return aiUsageDefaultReceiverPort + } + var cfg aiUsageDatadogConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return aiUsageDefaultReceiverPort + } + if cfg.APMConfig.ReceiverPort != nil && *cfg.APMConfig.ReceiverPort > 0 { + return *cfg.APMConfig.ReceiverPort + } + return aiUsageDefaultReceiverPort +} + +// readAIUsageChromeExtensionID resolves the Chrome extension ID from the active config, then the +// example, falling back to the compiled-in default. +func readAIUsageChromeExtensionID(configPath, examplePath string) string { + for _, p := range []string{configPath, examplePath} { + if id := readAIUsageChromeExtensionIDFromFile(p); id != "" { + return id + } + } + log.Debugf("AI Usage: no chrome_extension_id override found; using fallback %s", aiUsageFallbackChromeExtensionID) + return aiUsageFallbackChromeExtensionID +} + +func readAIUsageChromeExtensionIDFromFile(path string) string { + data, err := os.ReadFile(path) + if err != nil { + return "" + } + var cfg aiUsageNativeHostConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return "" + } + return strings.TrimSpace(cfg.ChromeExtensionID) +} + +// writeAIUsageManifest writes the Chrome native messaging host manifest, and cleans up the +// obsolete manifest if present. +func writeAIUsageManifest(manifestPath, hostExe, extensionID string) error { + if err := os.MkdirAll(filepath.Dir(manifestPath), 0o755); err != nil { + return fmt.Errorf("could not create manifest directory: %w", err) + } + obsolete := filepath.Join(filepath.Dir(manifestPath), aiUsageObsoleteNativeHostName+".json") + if err := os.Remove(obsolete); err != nil && !os.IsNotExist(err) { + log.Warnf("AI Usage: failed to delete obsolete manifest %q: %v", obsolete, err) + } + + manifest := aiUsageChromeManifest{ + Name: aiUsageNativeHostName, + Description: "Datadog AI usage native messaging host", + Path: hostExe, + Type: "stdio", + AllowedOrigins: []string{fmt.Sprintf("chrome-extension://%s/", extensionID)}, + } + data, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return fmt.Errorf("could not marshal AI Usage manifest: %w", err) + } + return os.WriteFile(manifestPath, append(data, '\n'), 0o644) +} + +// aiUsageChromeManifest is the Chrome native messaging host manifest. +type aiUsageChromeManifest struct { + Name string `json:"name"` + Description string `json:"description"` + Path string `json:"path"` + Type string `json:"type"` + AllowedOrigins []string `json:"allowed_origins"` +} + +// writeAIUsageChromeRegistry creates the two HKLM NativeMessagingHosts keys, with the (default) +// value set to the manifest path, in the 64-bit registry view. +func writeAIUsageChromeRegistry(manifestPath string) error { + for _, path := range aiUsageChromeRegKeyPaths { + key, _, err := registry.CreateKey(registry.LOCAL_MACHINE, path, registry.SET_VALUE|registry.WOW64_64KEY) + if err != nil { + return fmt.Errorf("could not create registry key %q: %w", path, err) + } + if err := key.SetStringValue("", manifestPath); err != nil { + key.Close() + return fmt.Errorf("could not set registry value for %q: %w", path, err) + } + key.Close() + } + return nil +} + +// configureAIUsageScheduledTask registers and starts the logon-triggered desktop monitor task. +func configureAIUsageScheduledTask(ctx context.Context, hostPath, configPath string) error { + tmp, err := os.CreateTemp("", "datadog-ai-usage-agent-*.xml") + if err != nil { + return fmt.Errorf("could not create temporary task XML: %w", err) + } + tmpPath := tmp.Name() + defer func() { + if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) { + log.Warnf("AI Usage: failed to remove temporary task XML %q: %v", tmpPath, err) + } + }() + taskXML, err := buildAIUsageTaskXML(hostPath, configPath) + if err != nil { + tmp.Close() + return fmt.Errorf("could not build task XML: %w", err) + } + encodedXML, err := encodeAIUsageTaskXML(taskXML) + if err != nil { + tmp.Close() + return fmt.Errorf("could not encode task XML: %w", err) + } + if _, err := tmp.Write(encodedXML); err != nil { + tmp.Close() + return fmt.Errorf("could not write task XML: %w", err) + } + tmp.Close() + + schtasks := filepath.Join(os.Getenv("SystemRoot"), "System32", "schtasks.exe") + if out, err := exec.CommandContext(ctx, schtasks, "/Create", "/TN", aiUsageTaskName, "/XML", tmpPath, "/F").CombinedOutput(); err != nil { + return fmt.Errorf("schtasks /Create failed: %w (%s)", err, strings.TrimSpace(string(out))) + } + // Start it now (best effort — it will otherwise start at next logon). + if out, err := exec.CommandContext(ctx, schtasks, "/Run", "/TN", aiUsageTaskName).CombinedOutput(); err != nil { + log.Warnf("AI Usage: schtasks /Run failed: %v (%s)", err, strings.TrimSpace(string(out))) + } + return nil +} + +// removeAIUsageScheduledTask ends and deletes the desktop monitor task (best effort). +func removeAIUsageScheduledTask(ctx context.Context) { + schtasks := filepath.Join(os.Getenv("SystemRoot"), "System32", "schtasks.exe") + _ = exec.CommandContext(ctx, schtasks, "/End", "/TN", aiUsageTaskName).Run() + _ = exec.CommandContext(ctx, schtasks, "/Delete", "/TN", aiUsageTaskName, "/F").Run() +} + +// buildAIUsageTaskXML builds the Task Scheduler definition for the desktop monitor: a +// logon-triggered task running as BUILTIN\Users at least privilege, parallel instances, +// restart-on-failure, no execution time limit. +func buildAIUsageTaskXML(hostPath, configPath string) (string, error) { + task := aiUsageScheduledTaskXML{ + Version: "1.4", + XMLNS: aiUsageTaskXMLNamespace, + RegistrationInfo: aiUsageTaskRegistrationInfo{ + Author: "Datadog", + Description: aiUsageTaskDescription, + }, + Triggers: aiUsageTaskTriggers{ + LogonTrigger: aiUsageTaskLogonTrigger{Enabled: true}, + }, + Principals: aiUsageTaskPrincipals{ + Principal: aiUsageTaskPrincipal{ + ID: "Author", + GroupID: aiUsageUsersGroupSID, + RunLevel: "LeastPrivilege", + }, + }, + Settings: aiUsageTaskSettings{ + MultipleInstancesPolicy: "Parallel", + DisallowStartIfOnBatteries: false, + StopIfGoingOnBatteries: false, + AllowHardTerminate: true, + StartWhenAvailable: false, + RunOnlyIfNetworkAvailable: false, + AllowStartOnDemand: true, + Enabled: true, + Hidden: false, + RunOnlyIfIdle: false, + WakeToRun: false, + RestartOnFailure: aiUsageTaskRestartOnFailure{ + Interval: "PT1M", + Count: 3, + }, + ExecutionTimeLimit: "PT0S", + Priority: 7, + }, + Actions: aiUsageTaskActions{ + Context: "Author", + Exec: aiUsageTaskExec{ + Command: hostPath, + Arguments: `--desktop-monitor --config "` + configPath + `"`, + }, + }, + } + + data, err := xml.Marshal(task) + if err != nil { + return "", err + } + return `` + string(data), nil +} + +// encodeAIUsageTaskXML encodes Task Scheduler XML as UTF-16LE with a BOM. +func encodeAIUsageTaskXML(taskXML string) ([]byte, error) { + return textunicode.UTF16(textunicode.LittleEndian, textunicode.UseBOM).NewEncoder().Bytes([]byte(taskXML)) +} + +type aiUsageScheduledTaskXML struct { + XMLName xml.Name `xml:"Task"` + Version string `xml:"version,attr"` + XMLNS string `xml:"xmlns,attr"` + RegistrationInfo aiUsageTaskRegistrationInfo `xml:"RegistrationInfo"` + Triggers aiUsageTaskTriggers `xml:"Triggers"` + Principals aiUsageTaskPrincipals `xml:"Principals"` + Settings aiUsageTaskSettings `xml:"Settings"` + Actions aiUsageTaskActions `xml:"Actions"` +} + +type aiUsageTaskRegistrationInfo struct { + Author string `xml:"Author"` + Description string `xml:"Description"` +} + +type aiUsageTaskTriggers struct { + LogonTrigger aiUsageTaskLogonTrigger `xml:"LogonTrigger"` +} + +type aiUsageTaskLogonTrigger struct { + Enabled bool `xml:"Enabled"` +} + +type aiUsageTaskPrincipals struct { + Principal aiUsageTaskPrincipal `xml:"Principal"` +} + +type aiUsageTaskPrincipal struct { + ID string `xml:"id,attr"` + GroupID string `xml:"GroupId"` + RunLevel string `xml:"RunLevel"` +} + +type aiUsageTaskSettings struct { + MultipleInstancesPolicy string `xml:"MultipleInstancesPolicy"` + DisallowStartIfOnBatteries bool `xml:"DisallowStartIfOnBatteries"` + StopIfGoingOnBatteries bool `xml:"StopIfGoingOnBatteries"` + AllowHardTerminate bool `xml:"AllowHardTerminate"` + StartWhenAvailable bool `xml:"StartWhenAvailable"` + RunOnlyIfNetworkAvailable bool `xml:"RunOnlyIfNetworkAvailable"` + AllowStartOnDemand bool `xml:"AllowStartOnDemand"` + Enabled bool `xml:"Enabled"` + Hidden bool `xml:"Hidden"` + RunOnlyIfIdle bool `xml:"RunOnlyIfIdle"` + WakeToRun bool `xml:"WakeToRun"` + RestartOnFailure aiUsageTaskRestartOnFailure `xml:"RestartOnFailure"` + ExecutionTimeLimit string `xml:"ExecutionTimeLimit"` + Priority int `xml:"Priority"` +} + +type aiUsageTaskRestartOnFailure struct { + Interval string `xml:"Interval"` + Count int `xml:"Count"` +} + +type aiUsageTaskActions struct { + Context string `xml:"Context,attr"` + Exec aiUsageTaskExec `xml:"Exec"` +} + +type aiUsageTaskExec struct { + Command string `xml:"Command"` + Arguments string `xml:"Arguments"` +} diff --git a/pkg/fleet/installer/packages/datadog_agent_eudm_windows_test.go b/pkg/fleet/installer/packages/datadog_agent_eudm_windows_test.go new file mode 100644 index 000000000000..5869528157f0 --- /dev/null +++ b/pkg/fleet/installer/packages/datadog_agent_eudm_windows_test.go @@ -0,0 +1,89 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +//go:build windows + +package packages + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEncodeAIUsageTaskXML(t *testing.T) { + out, err := encodeAIUsageTaskXML("Ab") + require.NoError(t, err) + // BOM (FF FE) followed by 'A' (41 00) and 'b' (62 00), little-endian. + require.Equal(t, []byte{0xFF, 0xFE, 0x41, 0x00, 0x62, 0x00}, out) +} + +func TestReadAIUsageChromeExtensionIDFromFile(t *testing.T) { + dir := t.TempDir() + + withID := filepath.Join(dir, "with_id.yaml") + require.NoError(t, os.WriteFile(withID, []byte("chrome_extension_id: \" abc123 \"\n"), 0o644)) + assert.Equal(t, "abc123", readAIUsageChromeExtensionIDFromFile(withID)) + + withoutID := filepath.Join(dir, "without_id.yaml") + require.NoError(t, os.WriteFile(withoutID, []byte("trace_agent_url: \"http://127.0.0.1:8126\"\n"), 0o644)) + assert.Equal(t, "", readAIUsageChromeExtensionIDFromFile(withoutID)) + + assert.Equal(t, "", readAIUsageChromeExtensionIDFromFile(filepath.Join(dir, "missing.yaml"))) +} + +func TestWriteAIUsageManifest(t *testing.T) { + dir := t.TempDir() + manifestPath := filepath.Join(dir, aiUsageNativeHostName+".json") + + // An obsolete manifest in the same dir should be cleaned up. + obsolete := filepath.Join(dir, aiUsageObsoleteNativeHostName+".json") + require.NoError(t, os.WriteFile(obsolete, []byte("{}"), 0o644)) + + require.NoError(t, writeAIUsageManifest(manifestPath, `C:\Program Files\Datadog\host.exe`, "extid")) + + data, err := os.ReadFile(manifestPath) + require.NoError(t, err) + manifest := string(data) + assert.Contains(t, manifest, `"name": "`+aiUsageNativeHostName+`"`) + assert.Contains(t, manifest, `"path": "C:\\Program Files\\Datadog\\host.exe"`) + assert.Contains(t, manifest, `"chrome-extension://extid/"`) + assert.NoFileExists(t, obsolete) +} + +func TestWriteAIUsageConfigSubstitutesTraceURLAndPreservesExisting(t *testing.T) { + dir := t.TempDir() + examplePath := filepath.Join(dir, aiUsageConfigName+".example") + configPath := filepath.Join(dir, aiUsageConfigName) + require.NoError(t, os.WriteFile(examplePath, []byte("# comment\ntrace_agent_url: \"http://127.0.0.1:8126\"\nevp_proxy_api_version: 2\n"), 0o644)) + + require.NoError(t, writeAIUsageConfig(examplePath, configPath)) + rendered, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Contains(t, string(rendered), "trace_agent_url: ") + assert.Contains(t, string(rendered), "evp_proxy_api_version: 2") + + // An existing config must be preserved. + require.NoError(t, os.WriteFile(configPath, []byte("preserved"), 0o644)) + require.NoError(t, writeAIUsageConfig(examplePath, configPath)) + after, err := os.ReadFile(configPath) + require.NoError(t, err) + assert.Equal(t, "preserved", string(after)) +} + +func TestBuildAIUsageTaskXML(t *testing.T) { + xml, err := buildAIUsageTaskXML(`C:\Program Files\Datadog\host.exe`, `C:\ProgramData\Datadog\ai_usage_native_host.yaml`) + require.NoError(t, err) + assert.True(t, strings.HasPrefix(xml, ``)) + assert.Contains(t, xml, "") + assert.Contains(t, xml, ""+aiUsageUsersGroupSID+"") + assert.Contains(t, xml, "LeastPrivilege") + assert.Contains(t, xml, "C:\\Program Files\\Datadog\\host.exe") + assert.Contains(t, xml, "--desktop-monitor --config "C:\\ProgramData\\Datadog\\ai_usage_native_host.yaml"") +} diff --git a/pkg/fleet/installer/packages/datadog_agent_extensions.go b/pkg/fleet/installer/packages/datadog_agent_extensions.go index 361b64d3f526..cefb8e98d192 100644 --- a/pkg/fleet/installer/packages/datadog_agent_extensions.go +++ b/pkg/fleet/installer/packages/datadog_agent_extensions.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "runtime" "strings" "gopkg.in/yaml.v3" @@ -41,8 +42,19 @@ func getCurrentAgentVersion() string { //nolint:unused // Used in platform-specific files type datadogAgentConfig struct { Installer installerConfig `yaml:"installer"` + // InfrastructureMode mirrors the top-level `infrastructure_mode` key in datadog.yaml + // (full|basic|end_user_device|none). Used to gate End User Device Monitoring + // extensions (e.g. eudm) when the mode is configured in datadog.yaml rather + // than passed as DD_INFRASTRUCTURE_MODE at install time. + InfrastructureMode string `yaml:"infrastructure_mode,omitempty"` } +// infrastructureModeEndUserDevice is the infrastructure_mode value that enables +// End User Device Monitoring (EUDM). +// +//nolint:unused // Used in platform-specific files +const infrastructureModeEndUserDevice = "end_user_device" + //nolint:unused // Used in platform-specific files type installerConfig struct { Registry installerRegistryConfig `yaml:"registry,omitempty"` @@ -70,16 +82,8 @@ type installerRegistryConfig struct { // //nolint:unused // Used in platform-specific files func setRegistryConfig(env *env.Env) map[string]extensionsPkg.ExtensionRegistry { - configPath := filepath.Join(paths.AgentConfigDir, "datadog.yaml") - rawConfig, err := os.ReadFile(configPath) - if err != nil { - log.Debugf("could not read agent config at %s: %v", configPath, err) - return nil - } - var config datadogAgentConfig - err = yaml.Unmarshal(rawConfig, &config) - if err != nil { - log.Warnf("could not parse agent config at %s: %v", configPath, err) + config, ok := loadDatadogAgentConfig() + if !ok { return nil } @@ -119,6 +123,61 @@ func setRegistryConfig(env *env.Env) map[string]extensionsPkg.ExtensionRegistry return overrides } +// isEndUserDeviceMode reports whether End User Device Monitoring (EUDM) is enabled. +// +// DD_INFRASTRUCTURE_MODE is authoritative when set: any explicit value that is not +// end_user_device disables EUDM, even if datadog.yaml still says otherwise. The +// infrastructure_mode value from datadog.yaml is only used as a fallback when the env var is +// blank (e.g. upgrades that do not re-supply DD_INFRASTRUCTURE_MODE). +// +//nolint:unused // Used in platform-specific files +func isEndUserDeviceMode(env *env.Env) bool { + return endUserDeviceModeEnabled(env.InfrastructureMode, readInfrastructureModeFromConfig) +} + +// endUserDeviceModeEnabled applies the EUDM gating precedence. envMode (DD_INFRASTRUCTURE_MODE) is +// authoritative when non-empty; configModeFn (the datadog.yaml value) is consulted only as a +// fallback when envMode is blank, and is not called otherwise. +// +//nolint:unused // Used in platform-specific files +func endUserDeviceModeEnabled(envMode string, configModeFn func() string) bool { + if envMode != "" { + return strings.EqualFold(envMode, infrastructureModeEndUserDevice) + } + return strings.EqualFold(configModeFn(), infrastructureModeEndUserDevice) +} + +// readInfrastructureModeFromConfig returns the infrastructure_mode value from datadog.yaml, +// or "" if the file cannot be read/parsed or the key is unset. +// +//nolint:unused // Used in platform-specific files +func readInfrastructureModeFromConfig() string { + config, ok := loadDatadogAgentConfig() + if !ok { + return "" + } + return config.InfrastructureMode +} + +// loadDatadogAgentConfig reads and parses the subset of the installed datadog.yaml that the +// installer cares about. It returns ok=false (best effort) if the file cannot be read or parsed. +// +//nolint:unused // Used in platform-specific files +func loadDatadogAgentConfig() (datadogAgentConfig, bool) { + configPath := filepath.Join(paths.AgentConfigDir, "datadog.yaml") + rawConfig, err := os.ReadFile(configPath) + if err != nil { + log.Debugf("could not read agent config at %s: %v", configPath, err) + return datadogAgentConfig{}, false + } + var config datadogAgentConfig + if err := yaml.Unmarshal(rawConfig, &config); err != nil { + log.Warnf("could not parse agent config at %s: %v", configPath, err) + return datadogAgentConfig{}, false + } + return config, true +} + // saveAgentExtensions saves the extensions of the Agent package by writing them to a file on disk. // the extensions can then be picked up by the restoreAgentExtensions function to restore them // @@ -157,6 +216,9 @@ func restoreAgentExtensions(ctx HookContext, version string, experiment bool) er url := oci.PackageURL(env, agentPackage, version) hooks := NewHooks(env, repository.NewRepositories(paths.PackagesPath, AsyncPreRemoveHooks)) + // Restore replays whatever extensions were previously installed. Once an extension is + // installed it stays installed across upgrades; enabling conditions (e.g. EUDM) only gate the + // initial install in installAgentExtensions, not restore. return extensionsPkg.Restore(ctx, downloader, agentPackage, url, storagePath, experiment, hooks, overrides) } @@ -172,6 +234,14 @@ func installAgentExtensions(ctx HookContext, version string, isExperiment bool) if env.OTelCollectorEnabled { extensions = append(extensions, "ddot") } + // The eudm extension (currently the AI Usage Chrome Native Messaging host + desktop monitor; + // a container for End User Device Monitoring features) is Windows-only and gated on EUDM. It + // is enabled when DD_INFRASTRUCTURE_MODE=end_user_device is passed at install time; when that + // env var is blank it falls back to infrastructure_mode: end_user_device in datadog.yaml + // (covers upgrades that do not re-supply the env var). See isEndUserDeviceMode. + if runtime.GOOS == "windows" && isEndUserDeviceMode(env) { + extensions = append(extensions, "eudm") + } // if no extensions are requested, return early if len(extensions) == 0 { return nil diff --git a/pkg/fleet/installer/packages/datadog_agent_extensions_test.go b/pkg/fleet/installer/packages/datadog_agent_extensions_test.go index e7df9c7ce5fe..3a8d1a94cf6f 100644 --- a/pkg/fleet/installer/packages/datadog_agent_extensions_test.go +++ b/pkg/fleet/installer/packages/datadog_agent_extensions_test.go @@ -86,6 +86,38 @@ installer: assert.Nil(t, config.Installer.Registry.Extensions) } +func TestEndUserDeviceModeEnabled(t *testing.T) { + const noConfigRead = "" + tests := []struct { + name string + envMode string // DD_INFRASTRUCTURE_MODE + configMode string // datadog.yaml infrastructure_mode; noConfigRead asserts it is not consulted + want bool + }{ + {name: "env end_user_device enables", envMode: "end_user_device", configMode: noConfigRead, want: true}, + {name: "env case-insensitive", envMode: "End_User_Device", configMode: noConfigRead, want: true}, + // env is authoritative when set: a non-EUDM env value disables EUDM without even reading + // the config, even though the config still says end_user_device. + {name: "env full overrides config end_user_device", envMode: "full", configMode: noConfigRead, want: false}, + {name: "env other value disables", envMode: "basic", configMode: noConfigRead, want: false}, + // config is only consulted when the env var is blank. + {name: "blank env falls back to config end_user_device", envMode: "", configMode: "end_user_device", want: true}, + {name: "blank env falls back to config full", envMode: "", configMode: "full", want: false}, + {name: "blank env and empty config", envMode: "", configMode: "", want: false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configModeFn := func() string { + if tt.configMode == noConfigRead { + t.Fatalf("config should not be read when DD_INFRASTRUCTURE_MODE is set") + } + return tt.configMode + } + assert.Equal(t, tt.want, endUserDeviceModeEnabled(tt.envMode, configModeFn)) + }) + } +} + func TestInstallDDOTExtensionIfEnabled_Disabled(t *testing.T) { t.Setenv("DD_OTELCOLLECTOR_ENABLED", "false") ctx := HookContext{Context: context.Background()} diff --git a/pkg/fleet/installer/packages/datadog_agent_windows.go b/pkg/fleet/installer/packages/datadog_agent_windows.go index d23109c4ef2c..0799821d2f5e 100644 --- a/pkg/fleet/installer/packages/datadog_agent_windows.go +++ b/pkg/fleet/installer/packages/datadog_agent_windows.go @@ -997,6 +997,8 @@ func preInstallExtensionDatadogAgent(ctx HookContext) error { switch ctx.Extension { case "ddot": return preInstallDDOTExtension(ctx) + case "eudm": + return preInstallEUDMExtension(ctx) default: return nil } @@ -1007,6 +1009,8 @@ func postInstallExtensionDatadogAgent(ctx HookContext) error { switch ctx.Extension { case "ddot": return postInstallDDOTExtension(ctx) + case "eudm": + return postInstallEUDMExtension(ctx) default: return nil } @@ -1017,6 +1021,8 @@ func preRemoveExtensionDatadogAgent(ctx HookContext) error { switch ctx.Extension { case "ddot": return preRemoveDDOTExtension(ctx) + case "eudm": + return preRemoveEUDMExtension(ctx) default: return nil } diff --git a/releasenotes/notes/ai-usage-eudm-fleet-extension-59350ff4e4c802b0.yaml b/releasenotes/notes/ai-usage-eudm-fleet-extension-59350ff4e4c802b0.yaml new file mode 100644 index 000000000000..74d6db606900 --- /dev/null +++ b/releasenotes/notes/ai-usage-eudm-fleet-extension-59350ff4e4c802b0.yaml @@ -0,0 +1,8 @@ +--- +enhancements: + - | + On Windows, the AI Usage Chrome Native Messaging host is now delivered as a + fleet-managed Agent extension that is only installed when End User Device + Monitoring is enabled (``infrastructure_mode: end_user_device``). It is no + longer unconditionally installed by the MSI, and is skipped on Agent + upgrades when End User Device Monitoring is disabled. diff --git a/tasks/msi.py b/tasks/msi.py index a7149be76d44..3291eea7687c 100644 --- a/tasks/msi.py +++ b/tasks/msi.py @@ -37,6 +37,7 @@ BUILD_SOURCE_DIR = os.path.join(BUILD_ROOT_DIR, "src") BUILD_OUTPUT_DIR = os.path.join(BUILD_ROOT_DIR, "output") DDOT_ARTIFACT_DIR = os.path.join('C:\\', 'opt', 'datadog-agent-ddot') +EUDM_ARTIFACT_DIR = os.path.join('C:\\', 'opt', 'datadog-agent-eudm') # Match to AgentInstaller.cs BinSource AGENT_BIN_SOURCE_DIR = os.path.join('C:\\', 'opt', 'datadog-agent', 'bin', 'agent') @@ -660,9 +661,10 @@ def fetch_driver_msm(ctx, drivers=None): help={ 'ref': 'The name of the ref (branch, tag) to fetch the latest artifacts from', 'ddot': 'Also download the DDOT zip artifact (default: False)', + 'eudm': 'Also download the EUDM extension zip artifact (AI Usage host) (default: False)', }, ) -def fetch_artifacts(ctx, ref: str | None = None, ddot: bool = False) -> None: +def fetch_artifacts(ctx, ref: str | None = None, ddot: bool = False, eudm: bool = False) -> None: """ Initialize the build environment with artifacts from a ref (default: main) @@ -670,6 +672,7 @@ def fetch_artifacts(ctx, ref: str | None = None, ddot: bool = False) -> None: dda inv msi.fetch-artifacts --ref main dda inv msi.fetch-artifacts --ref 7.66.x dda inv msi.fetch-artifacts --ref main --ddot + dda inv msi.fetch-artifacts --ref main --eudm """ if ref is None: ref = 'main' @@ -682,20 +685,29 @@ def fetch_artifacts(ctx, ref: str | None = None, ddot: bool = False) -> None: if ddot: download_latest_artifacts_for_ref(project, ref, tmp_dir, job='windows_zip_ddot_x64') + if eudm: + download_latest_artifacts_for_ref(project, ref, tmp_dir, job='windows_zip_eudm_x64') + tmp_dir_path = Path(tmp_dir) print(f"Downloaded artifacts to {tmp_dir_path}") # Recursively search for the zip files ddot_zips = list(tmp_dir_path.glob("**/datadog-agent-ddot-*x86_64.zip")) + eudm_zips = list(tmp_dir_path.glob("**/datadog-agent-eudm-*x86_64.zip")) ddot_set = set(ddot_zips) - agent_zips = [z for z in tmp_dir_path.glob("**/datadog-agent-*-x86_64.zip") if z not in ddot_set] + eudm_set = set(eudm_zips) + agent_zips = [ + z for z in tmp_dir_path.glob("**/datadog-agent-*-x86_64.zip") if z not in ddot_set and z not in eudm_set + ] installer_zips = list(tmp_dir_path.glob("**/datadog-installer-*-x86_64.zip")) print(f"Found {len(agent_zips)} agent zip files") print(f"Found {len(installer_zips)} installer zip files") if ddot: print(f"Found {len(ddot_zips)} DDOT zip files") + if eudm: + print(f"Found {len(eudm_zips)} EUDM extension zip files") if not agent_zips and not installer_zips: print("No zip files found. Directory contents:") @@ -729,6 +741,15 @@ def fetch_artifacts(ctx, ref: str | None = None, ddot: bool = False) -> None: with zipfile.ZipFile(zip_file, "r") as zip_ref: zip_ref.extractall(dest) + # Extract EUDM extension zips + if eudm_zips: + dest = Path(EUDM_ARTIFACT_DIR) + dest.mkdir(parents=True, exist_ok=True) + for zip_file in eudm_zips: + print(f"Extracting {zip_file} to {dest}") + with zipfile.ZipFile(zip_file, "r") as zip_ref: + zip_ref.extractall(dest) + print("Extraction complete") # Delete stale embedded3.COMPRESSED so the next debug build re-compresses @@ -783,6 +804,8 @@ def writewrapper(b: bytes) -> None: 'source_type': "Source type - 'msi' or 'zip' (default: msi)", 'ddot': 'Include the DDOT extension layer (auto-detected from fetch-artifacts --ddot)', 'ddot_path': 'Explicit path to the extracted DDOT artifact directory (overrides auto-detect)', + 'eudm': 'Include the EUDM extension layer, AI Usage host (auto-detected from fetch-artifacts --eudm)', + 'eudm_path': 'Explicit path to the extracted EUDM extension artifact directory (overrides auto-detect)', }, ) def package_oci( @@ -792,6 +815,8 @@ def package_oci( source_type="msi", ddot=False, ddot_path=None, + eudm=False, + eudm_path=None, ): """ Create an OCI package from an MSI installer. @@ -800,10 +825,14 @@ def package_oci( auto-detected from the directory populated by fetch-artifacts --ddot. Use --ddot-path to override with an explicit directory. + Use --eudm (or --eudm-path) to include the EUDM extension layer (AI Usage host), + analogous to --ddot. + Example: dda inv msi.package-oci dda inv msi.package-oci --ddot dda inv msi.package-oci --ddot-path C:\\path\\to\\extracted-ddot + dda inv msi.package-oci --eudm Requires: datadog-package: Install from https://github.com/DataDog/datadog-package @@ -889,6 +918,23 @@ def package_oci( ddot_ext_dir = str(ddot_dir) print(f"Auto-detected DDOT directory: {ddot_ext_dir}") + # Resolve EUDM extension directory + eudm_ext_dir = None + if eudm_path is not None: + if not os.path.isdir(eudm_path): + print(f"EUDM extension directory not found: {eudm_path}") + raise Exit(code=1) + eudm_ext_dir = eudm_path + print(f"Using EUDM extension directory: {eudm_ext_dir}") + elif eudm: + eudm_dir = Path(EUDM_ARTIFACT_DIR) + if not eudm_dir.exists() or not any(eudm_dir.iterdir()): + print(f"No EUDM extension artifacts found in {eudm_dir}") + print("Run 'dda inv msi.fetch-artifacts --eudm' first, or provide --eudm-path.") + raise Exit(code=1) + eudm_ext_dir = str(eudm_dir) + print(f"Auto-detected EUDM extension directory: {eudm_ext_dir}") + installer_bin_path = 'C:\\opt\\datadog-installer\\datadog-installer.exe' if os.path.exists(installer_bin_path): print(f"Using installer binary: {installer_bin_path}") @@ -922,6 +968,8 @@ def package_oci( if ddot_ext_dir: extra_flags += f' --extension ddot={ddot_ext_dir}' + if eudm_ext_dir: + extra_flags += f' --extension eudm={eudm_ext_dir}' if installer_bin_path: extra_flags += f' --installer {installer_bin_path}' diff --git a/test/new-e2e/tests/installer/windows/eudm_msi_install_test.go b/test/new-e2e/tests/installer/windows/eudm_msi_install_test.go new file mode 100644 index 000000000000..51e63544c087 --- /dev/null +++ b/test/new-e2e/tests/installer/windows/eudm_msi_install_test.go @@ -0,0 +1,181 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +//go:build !e2eunit + +package installer + +import ( + "fmt" + "path/filepath" + "testing" + + "github.com/DataDog/datadog-agent/test/e2e-framework/testing/e2e" + winawshost "github.com/DataDog/datadog-agent/test/e2e-framework/testing/provisioners/aws/host/windows" + installer "github.com/DataDog/datadog-agent/test/new-e2e/tests/installer/unix" + "github.com/DataDog/datadog-agent/test/new-e2e/tests/installer/windows/consts" +) + +const ( + // eudmExtensionName is the fleet installer extension name (End User Device Monitoring). + eudmExtensionName = "eudm" + // aiUsageBinaryName is the AI Usage native host binary shipped in the eudm extension layer. + aiUsageBinaryName = "ai-usage-agent-native-host.exe" + // aiUsageConfigDir is the ProgramData config directory where the generated config lives. + aiUsageConfigDir = `C:\ProgramData\Datadog` + // aiUsageConfigName is the generated runtime config. + aiUsageConfigName = "ai_usage_native_host.yaml" + // aiUsageNativeHostName is the Chrome native messaging host name (manifest + registry key). + aiUsageNativeHostName = "com.datadoghq.ai_usage_agent.native_host" + // aiUsageScheduledTaskName is the desktop-monitor scheduled task. + aiUsageScheduledTaskName = "Datadog AI Usage Agent" + // aiUsageChromeRegistryKey is the machine-wide Chrome NativeMessagingHosts registration. + aiUsageChromeRegistryKey = `HKLM:\Software\Google\Chrome\NativeMessagingHosts\` + aiUsageNativeHostName +) + +type testEUDMExtensionMSI struct { + BaseSuite +} + +// TestEUDMExtensionViaMSI verifies that the eudm extension (currently the AI Usage Chrome Native +// Messaging host + desktop monitor) is installed when End User Device Monitoring is enabled at +// MSI install time (DD_INFRASTRUCTURE_MODE=end_user_device), skipped otherwise, and removed on +// uninstall. Mirrors ddot_msi_install_test.go. +func TestEUDMExtensionViaMSI(t *testing.T) { + e2e.Run(t, &testEUDMExtensionMSI{}, + e2e.WithProvisioner( + winawshost.ProvisionerNoAgentNoFakeIntake(), + )) +} + +// TestInstallAndUninstallEUDMExtension installs the current Agent MSI with EUDM enabled and +// asserts the eudm extension layer landed and all runtime artifacts were created, then +// uninstalls and asserts they are gone. +func (s *testEUDMExtensionMSI) TestInstallAndUninstallEUDMExtension() { + // Act: install current Agent MSI with EUDM enabled + s.Require().NoError(s.Installer().Install( + WithOption(WithInstallerURL(s.CurrentAgentVersion().MSIPackage().URL)), + WithMSILogFile("install-eudm-extension.log"), + WithMSIArg("APIKEY="+installer.GetAPIKey()), + WithMSIArg("SITE=datadoghq.com"), + WithMSIArg("DD_INSTALLER_REGISTRY_URL="+consts.PipelineOCIRegistry), + WithMSIArg("DD_INFRASTRUCTURE_MODE=end_user_device"), + )) + + // Assert: the eudm extension layer landed under the agent package + eudmExtDir := filepath.Join(consts.GetStableDirFor(consts.AgentPackage), "ext", eudmExtensionName) + s.Require().Host(s.Env().RemoteHost).DirExists(eudmExtDir, "eudm extension directory should exist") + s.Require().Host(s.Env().RemoteHost).FileExists( + filepath.Join(eudmExtDir, aiUsageBinaryName), + "native host binary should be present in the eudm extension layer", + ) + + // Assert: the extension hook laid down the runtime artifacts + s.assertAIUsageInstalled() + + // Act: uninstall the Agent MSI (purges OCI packages including extensions) + s.Require().NoError(s.Installer().Uninstall( + WithMSILogFile("uninstall-eudm-extension.log"), + )) + + // Assert: the extension layer and its runtime artifacts are gone + s.Require().Host(s.Env().RemoteHost).NoDirExists(eudmExtDir, "eudm extension should be removed on uninstall") + s.Require().Host(s.Env().RemoteHost).HasNoRegistryKey(aiUsageChromeRegistryKey) + s.assertAIUsageScheduledTask(false) +} + +// TestUpgradeEnablesEUDMExtension installs a stable Agent without EUDM, then upgrades to the +// current version with EUDM enabled, and verifies the eudm extension gets installed. +func (s *testEUDMExtensionMSI) TestUpgradeEnablesEUDMExtension() { + // Arrange: install a stable Agent without EUDM + s.installPreviousAgentVersion() + + // Sanity: the eudm extension is not present before enabling EUDM + eudmExtDir := filepath.Join(consts.GetStableDirFor(consts.AgentPackage), "ext", eudmExtensionName) + s.Require().Host(s.Env().RemoteHost).NoDirExists(eudmExtDir, "eudm extension should be absent before EUDM is enabled") + + // Act: upgrade to current version with EUDM enabled + s.Require().NoError(s.Installer().Install( + WithOption(WithInstallerURL(s.CurrentAgentVersion().MSIPackage().URL)), + WithMSILogFile("upgrade-enable-eudm.log"), + WithMSIArg("DD_INSTALLER_REGISTRY_URL="+consts.PipelineOCIRegistry), + WithMSIArg("DD_INFRASTRUCTURE_MODE=end_user_device"), + )) + + // Assert: the eudm extension is installed + s.Require().Host(s.Env().RemoteHost).DirExists(eudmExtDir, "eudm extension directory should exist after upgrade") + s.assertAIUsageInstalled() +} + +// TestInstallWithoutEUDMSkipsExtension installs the current Agent MSI without EUDM and +// verifies the eudm extension is NOT installed (the whole point of the EUDM gate). +func (s *testEUDMExtensionMSI) TestInstallWithoutEUDMSkipsExtension() { + // Act: install current Agent MSI without EUDM + s.Require().NoError(s.Installer().Install( + WithOption(WithInstallerURL(s.CurrentAgentVersion().MSIPackage().URL)), + WithMSILogFile("install-no-eudm.log"), + WithMSIArg("APIKEY="+installer.GetAPIKey()), + WithMSIArg("SITE=datadoghq.com"), + WithMSIArg("DD_INSTALLER_REGISTRY_URL="+consts.PipelineOCIRegistry), + )) + + // Assert: no eudm extension, no Chrome registration, no scheduled task + eudmExtDir := filepath.Join(consts.GetStableDirFor(consts.AgentPackage), "ext", eudmExtensionName) + s.Require().Host(s.Env().RemoteHost).NoDirExists(eudmExtDir, "eudm extension should not be installed without EUDM") + s.Require().Host(s.Env().RemoteHost).HasNoRegistryKey(aiUsageChromeRegistryKey) + s.assertAIUsageScheduledTask(false) +} + +// assertAIUsageInstalled asserts every artifact the eudm extension's AI Usage hook creates on +// install: the native host binary and Chrome host manifest in the extension layer, the generated +// config in ProgramData, the HKLM Chrome registration, and the logon-triggered scheduled task. +func (s *testEUDMExtensionMSI) assertAIUsageInstalled() { + eudmExtDir := filepath.Join(consts.GetStableDirFor(consts.AgentPackage), "ext", eudmExtensionName) + s.Require().Host(s.Env().RemoteHost).FileExists( + filepath.Join(eudmExtDir, aiUsageBinaryName), + "native host binary should be present in the extension layer", + ) + s.Require().Host(s.Env().RemoteHost).FileExists( + filepath.Join(eudmExtDir, aiUsageNativeHostName+".json"), + "Chrome native messaging manifest should be present in the extension layer", + ) + s.Require().Host(s.Env().RemoteHost).FileExists( + filepath.Join(aiUsageConfigDir, aiUsageConfigName), + "ai_usage_native_host.yaml should be generated in ProgramData", + ) + s.Require().Host(s.Env().RemoteHost).HasRegistryKey(aiUsageChromeRegistryKey) + s.assertAIUsageScheduledTask(true) +} + +// assertAIUsageScheduledTask asserts the "Datadog AI Usage Agent" scheduled task is present +// (exists=true) or absent (exists=false). +func (s *testEUDMExtensionMSI) assertAIUsageScheduledTask(exists bool) { + _, err := s.Env().RemoteHost.Execute(fmt.Sprintf(`schtasks /query /TN "%s"`, aiUsageScheduledTaskName)) + if exists { + s.Require().NoError(err, "the AI Usage desktop monitor scheduled task should exist") + } else { + s.Require().Error(err, "the AI Usage desktop monitor scheduled task should not exist") + } +} + +// installPreviousAgentVersion installs the stable Agent MSI as an upgrade baseline. +func (s *testEUDMExtensionMSI) installPreviousAgentVersion(opts ...MsiOption) { + agentVersion := s.StableAgentVersion().Version() + options := []MsiOption{ + WithOption(WithInstallerURL(s.StableAgentVersion().MSIPackage().URL)), + WithMSILogFile("install-previous-version.log"), + WithMSIArg("APIKEY=" + installer.GetAPIKey()), + WithMSIArg("SITE=datadoghq.com"), + } + options = append(options, opts...) + s.Require().NoError(s.Installer().Install(options...)) + + // sanity: ensure the stable version is installed + s.Require().Host(s.Env().RemoteHost). + HasDatadogInstaller(). + WithVersionMatchPredicate(func(version string) { + s.Require().Contains(version, agentVersion) + }) +} diff --git a/tools/windows/DatadogAgentInstaller/AgentCustomActions/CustomAction.cs b/tools/windows/DatadogAgentInstaller/AgentCustomActions/CustomAction.cs index e80d28dc8df1..127aaf59f5cb 100644 --- a/tools/windows/DatadogAgentInstaller/AgentCustomActions/CustomAction.cs +++ b/tools/windows/DatadogAgentInstaller/AgentCustomActions/CustomAction.cs @@ -59,18 +59,6 @@ public static ActionResult SetupInstaller(Session session) return Datadog.CustomActions.SetupInstallerCustomAction.SetupInstaller(session); } - [CustomAction] - public static ActionResult ConfigureAiUsageMonitorDesktopMonitor(Session session) - { - return Datadog.CustomActions.AiUsageDesktopMonitorCustomAction.Configure(session); - } - - [CustomAction] - public static ActionResult RemoveAiUsageMonitorDesktopMonitor(Session session) - { - return Datadog.CustomActions.AiUsageDesktopMonitorCustomAction.Remove(session); - } - [CustomAction] public static ActionResult ReportFailure(Session session) { diff --git a/tools/windows/DatadogAgentInstaller/CustomActions.Tests/WriteConfigUnitTests.cs b/tools/windows/DatadogAgentInstaller/CustomActions.Tests/WriteConfigUnitTests.cs index 1f368f80eaa5..07815d2bf69a 100644 --- a/tools/windows/DatadogAgentInstaller/CustomActions.Tests/WriteConfigUnitTests.cs +++ b/tools/windows/DatadogAgentInstaller/CustomActions.Tests/WriteConfigUnitTests.cs @@ -1,11 +1,7 @@ -using System; -using System.IO; -using System.Reflection; using AutoFixture.Xunit2; using CustomActions.Tests.Helpers; using Datadog.CustomActions; using Moq; -using WixToolset.Dtf.WindowsInstaller; using Xunit; using YamlDotNet.RepresentationModel; using ISession = Datadog.CustomActions.Interfaces.ISession; @@ -14,8 +10,6 @@ namespace CustomActions.Tests { public class WriteConfigUnitTests { - private const string CustomChromeExtensionId = "abcdefghijklmnopabcdefghijklmnop"; - [Theory] [InlineAutoData("APIKEY", "api_key")] [InlineAutoData("SITE", "site")] @@ -84,153 +78,5 @@ public void Missing_Properties_Should_Be_Appended(string property, string key, s .HaveKey(key) .And.HaveValue(value); } - - [Theory] - [InlineAutoData] - public void WriteConfig_Should_Generate_AiUsageNativeHostConfig_With_Default_Apm_Port(Mock sessionMock) - { - WithTempInstallFolders((configFolder, projectLocation) => - { - File.WriteAllText(Path.Combine(configFolder, "datadog.yaml.example"), "api_key:\n"); - WriteAiUsageNativeHostExample(configFolder); - Directory.CreateDirectory(AiUsageManifestDir(projectLocation)); - File.WriteAllText(Path.Combine(AiUsageManifestDir(projectLocation), "com.datadoghq.ai_prompt_logger.native_host.json"), "{}"); - sessionMock.Setup(session => session["APPLICATIONDATADIRECTORY"]).Returns(configFolder); - sessionMock.Setup(session => session["PROJECTLOCATION"]).Returns(projectLocation); - - var result = InvokeWriteConfig(sessionMock.Object); - - Assert.Equal(ActionResult.Success, result); - var aiUsageYaml = File.ReadAllText(Path.Combine(configFolder, "ai_usage_native_host.yaml")); - Assert.Contains("trace_agent_url: \"http://127.0.0.1:8126\"", aiUsageYaml); - Assert.DoesNotContain("chrome_extension_id:", aiUsageYaml); - - var manifest = File.ReadAllText(AiUsageManifestPath(projectLocation)); - Assert.Contains("\"name\": \"com.datadoghq.ai_usage_agent.native_host\"", manifest); - Assert.Contains("\"path\": \"" + Path.Combine(projectLocation, "bin", "agent", "ai-usage-agent-native-host.exe").Replace("\\", "\\\\") + "\"", manifest); - Assert.Contains($"\"chrome-extension://{Constants.FallbackAiUsageChromeExtensionId}/\"", manifest); - Assert.False(File.Exists(Path.Combine(AiUsageManifestDir(projectLocation), "com.datadoghq.ai_prompt_logger.native_host.json"))); - }); - } - - [Theory] - [InlineAutoData] - public void WriteConfig_Should_Generate_AiUsageNativeHostConfig_With_DatadogYaml_Apm_Port(Mock sessionMock) - { - WithTempInstallFolders((configFolder, projectLocation) => - { - File.WriteAllText(Path.Combine(configFolder, "datadog.yaml.example"), "api_key:\n"); - File.WriteAllText( - Path.Combine(configFolder, "datadog.yaml"), - "apm_config:\n enabled: true\n receiver_port: 8136\n"); - WriteAiUsageNativeHostExample(configFolder); - sessionMock.Setup(session => session["APPLICATIONDATADIRECTORY"]).Returns(configFolder); - sessionMock.Setup(session => session["PROJECTLOCATION"]).Returns(projectLocation); - - var result = InvokeWriteConfig(sessionMock.Object); - - Assert.Equal(ActionResult.Success, result); - var aiUsageYaml = File.ReadAllText(Path.Combine(configFolder, "ai_usage_native_host.yaml")); - Assert.Contains("trace_agent_url: \"http://127.0.0.1:8136\"", aiUsageYaml); - }); - } - - [Theory] - [InlineAutoData] - public void WriteConfig_Should_Not_Overwrite_Existing_AiUsageNativeHostConfig(Mock sessionMock) - { - WithTempInstallFolders((configFolder, projectLocation) => - { - var existingAiUsageConfig = "trace_agent_url: \"http://127.0.0.1:9999\"\n" + - $"chrome_extension_id: \"{CustomChromeExtensionId}\"\n"; - File.WriteAllText(Path.Combine(configFolder, "datadog.yaml.example"), "api_key:\n"); - File.WriteAllText( - Path.Combine(configFolder, "datadog.yaml"), - "apm_config:\n receiver_port: 8136\n"); - WriteAiUsageNativeHostExample(configFolder); - File.WriteAllText(Path.Combine(configFolder, "ai_usage_native_host.yaml"), existingAiUsageConfig); - sessionMock.Setup(session => session["APPLICATIONDATADIRECTORY"]).Returns(configFolder); - sessionMock.Setup(session => session["PROJECTLOCATION"]).Returns(projectLocation); - - var result = InvokeWriteConfig(sessionMock.Object); - - Assert.Equal(ActionResult.Success, result); - Assert.Equal(existingAiUsageConfig, File.ReadAllText(Path.Combine(configFolder, "ai_usage_native_host.yaml"))); - var manifest = File.ReadAllText(AiUsageManifestPath(projectLocation)); - Assert.Contains($"\"chrome-extension://{CustomChromeExtensionId}/\"", manifest); - }); - } - - [Theory] - [InlineAutoData] - public void WriteConfig_Should_Generate_AiUsageNativeHostManifest_With_Fallback_Extension_Id_When_Config_Is_Missing(Mock sessionMock) - { - WithTempInstallFolders((configFolder, projectLocation) => - { - File.WriteAllText(Path.Combine(configFolder, "datadog.yaml.example"), "api_key:\n"); - sessionMock.Setup(session => session["APPLICATIONDATADIRECTORY"]).Returns(configFolder); - sessionMock.Setup(session => session["PROJECTLOCATION"]).Returns(projectLocation); - - var result = InvokeWriteConfig(sessionMock.Object); - - Assert.Equal(ActionResult.Success, result); - var manifest = File.ReadAllText(AiUsageManifestPath(projectLocation)); - Assert.Contains($"\"chrome-extension://{Constants.FallbackAiUsageChromeExtensionId}/\"", manifest); - sessionMock.Verify( - session => session.Log( - It.Is(message => message.Contains("using fallback Chrome extension ID")), - It.IsAny(), - It.IsAny(), - It.IsAny()), - Times.Once); - }); - } - - private static ActionResult InvokeWriteConfig(ISession session) - { - var method = typeof(ConfigCustomActions).GetMethod( - "WriteConfig", - BindingFlags.NonPublic | BindingFlags.Static, - null, - new[] { typeof(ISession) }, - null); - return (ActionResult)method.Invoke(null, new object[] { session }); - } - - private static void WithTempInstallFolders(Action action) - { - var tempRoot = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - var configFolder = Path.Combine(tempRoot, "programdata", "Datadog"); - var projectLocation = Path.Combine(tempRoot, "programfiles", "Datadog Agent"); - Directory.CreateDirectory(configFolder); - Directory.CreateDirectory(projectLocation); - try - { - action(configFolder, projectLocation); - } - finally - { - Directory.Delete(tempRoot, recursive: true); - } - } - - private static string AiUsageManifestPath(string projectLocation) - { - return Path.Combine(projectLocation, "bin", "agent", "dist", "com.datadoghq.ai_usage_agent.native_host.json"); - } - - private static string AiUsageManifestDir(string projectLocation) - { - return Path.Combine(projectLocation, "bin", "agent", "dist"); - } - - private static void WriteAiUsageNativeHostExample(string configFolder) - { - File.WriteAllText( - Path.Combine(configFolder, "ai_usage_native_host.yaml.example"), - "trace_agent_url: \"http://127.0.0.1:8126\"\n" + - "evp_proxy_api_version: 2\n" + - "logs_evp_subdomain: \"http-intake.logs\"\n"); - } } } diff --git a/tools/windows/DatadogAgentInstaller/CustomActions/AiUsageDesktopMonitorCustomAction.cs b/tools/windows/DatadogAgentInstaller/CustomActions/AiUsageDesktopMonitorCustomAction.cs deleted file mode 100644 index 7be29be1607c..000000000000 --- a/tools/windows/DatadogAgentInstaller/CustomActions/AiUsageDesktopMonitorCustomAction.cs +++ /dev/null @@ -1,146 +0,0 @@ -using Datadog.CustomActions.Extensions; -using Datadog.CustomActions.Interfaces; -using WixToolset.Dtf.WindowsInstaller; -using System; -using System.IO; -using System.Text; -using System.Xml.Linq; - -namespace Datadog.CustomActions -{ - public class AiUsageDesktopMonitorCustomAction - { - private const string TaskName = "Datadog AI Usage Agent"; - private const string TaskDescription = "Starts the Datadog AI Usage Agent desktop monitor in the interactive user session."; - private const string UsersGroupSid = "S-1-5-32-545"; - - private static ActionResult Configure(ISession session) - { - var projectLocation = session.Property("PROJECTLOCATION"); - var configRoot = session.Property("APPLICATIONDATADIRECTORY"); - if (string.IsNullOrEmpty(projectLocation) || string.IsNullOrEmpty(configRoot)) - { - session.Log("Skipping AI Usage Agent desktop monitor task registration: install paths are unavailable."); - return ActionResult.Success; - } - - var hostPath = Path.Combine(projectLocation, "bin", "agent", "ai-usage-agent-native-host.exe"); - var configPath = Path.Combine(configRoot, "ai_usage_native_host.yaml"); - var taskXmlPath = Path.Combine(Path.GetTempPath(), $"datadog-ai-usage-agent-{Guid.NewGuid():N}.xml"); - var schtasks = Path.Combine(Environment.SystemDirectory, "schtasks.exe"); - - try - { - File.WriteAllText(taskXmlPath, BuildTaskXml(hostPath, configPath), Encoding.Unicode); - using (var proc = session.RunCommand( - schtasks, - $"/Create /TN \"{TaskName}\" /XML \"{taskXmlPath}\" /F")) - { - if (proc.ExitCode != 0) - { - session.Log($"AI Usage Agent desktop monitor task registration exited with code: {proc.ExitCode}"); - return ActionResult.Failure; - } - } - - session.Log("AI Usage Agent desktop monitor task registered."); - using (var runProc = session.RunCommand(schtasks, $"/Run /TN \"{TaskName}\"")) - { - if (runProc.ExitCode != 0) - { - session.Log($"AI Usage Agent desktop monitor task start exited with code: {runProc.ExitCode}"); - } - } - - return ActionResult.Success; - } - finally - { - try - { - File.Delete(taskXmlPath); - } - catch (Exception e) - { - session.Log($"Failed to delete temporary AI Usage Agent task XML: {e}"); - } - } - } - - private static ActionResult Remove(ISession session) - { - var schtasks = Path.Combine(Environment.SystemDirectory, "schtasks.exe"); - using (var endProc = session.RunCommand(schtasks, $"/End /TN \"{TaskName}\"")) - { - if (endProc.ExitCode != 0) - { - session.Log($"AI Usage Agent desktop monitor task end exited with code: {endProc.ExitCode}"); - } - } - - using (var deleteProc = session.RunCommand(schtasks, $"/Delete /TN \"{TaskName}\" /F")) - { - if (deleteProc.ExitCode != 0) - { - session.Log($"AI Usage Agent desktop monitor task deletion exited with code: {deleteProc.ExitCode}"); - } - } - - return ActionResult.Success; - } - - private static string BuildTaskXml(string hostPath, string configPath) - { - XNamespace ns = "http://schemas.microsoft.com/windows/2004/02/mit/task"; - var document = new XDocument( - new XDeclaration("1.0", "UTF-16", null), - new XElement(ns + "Task", - new XAttribute("version", "1.4"), - new XElement(ns + "RegistrationInfo", - new XElement(ns + "Author", "Datadog"), - new XElement(ns + "Description", TaskDescription)), - new XElement(ns + "Triggers", - new XElement(ns + "LogonTrigger", - new XElement(ns + "Enabled", "true"))), - new XElement(ns + "Principals", - new XElement(ns + "Principal", - new XAttribute("id", "Author"), - new XElement(ns + "GroupId", UsersGroupSid), - new XElement(ns + "RunLevel", "LeastPrivilege"))), - new XElement(ns + "Settings", - new XElement(ns + "MultipleInstancesPolicy", "Parallel"), - new XElement(ns + "DisallowStartIfOnBatteries", "false"), - new XElement(ns + "StopIfGoingOnBatteries", "false"), - new XElement(ns + "AllowHardTerminate", "true"), - new XElement(ns + "StartWhenAvailable", "false"), - new XElement(ns + "RunOnlyIfNetworkAvailable", "false"), - new XElement(ns + "AllowStartOnDemand", "true"), - new XElement(ns + "Enabled", "true"), - new XElement(ns + "Hidden", "false"), - new XElement(ns + "RunOnlyIfIdle", "false"), - new XElement(ns + "WakeToRun", "false"), - new XElement(ns + "RestartOnFailure", - new XElement(ns + "Interval", "PT1M"), - new XElement(ns + "Count", "3")), - new XElement(ns + "ExecutionTimeLimit", "PT0S"), - new XElement(ns + "Priority", "7")), - new XElement(ns + "Actions", - new XAttribute("Context", "Author"), - new XElement(ns + "Exec", - new XElement(ns + "Command", hostPath), - new XElement(ns + "Arguments", $"--desktop-monitor --config \"{configPath}\""))))); - - return document.ToString(SaveOptions.DisableFormatting); - } - - public static ActionResult Configure(Session session) - { - return Configure(new SessionWrapper(session)); - } - - public static ActionResult Remove(Session session) - { - return Remove(new SessionWrapper(session)); - } - } -} diff --git a/tools/windows/DatadogAgentInstaller/CustomActions/ConfigCustomActions.cs b/tools/windows/DatadogAgentInstaller/CustomActions/ConfigCustomActions.cs index 5bdad6ef69df..2cb29ad1c71d 100644 --- a/tools/windows/DatadogAgentInstaller/CustomActions/ConfigCustomActions.cs +++ b/tools/windows/DatadogAgentInstaller/CustomActions/ConfigCustomActions.cs @@ -16,13 +16,6 @@ namespace Datadog.CustomActions { public class ConfigCustomActions { - private const string AiUsageNativeHostConfigName = "ai_usage_native_host.yaml"; - private const string AiUsageNativeHostName = "com.datadoghq.ai_usage_agent.native_host"; - private static readonly string[] ObsoleteAiUsageNativeHostNames = - { - "com.datadoghq.ai_prompt_logger.native_host", - }; - /// /// Subset of the Datadog config file that we are going to read. /// @@ -33,13 +26,6 @@ class ApmConfig public int? ReceiverPort { get; set; } } - // ReSharper disable once ArrangeTypeMemberModifiers - class AiUsageNativeHostConfig - { - // ReSharper disable once UnusedAutoPropertyAccessor.Local - public string ChromeExtensionId { get; set; } - } - // ReSharper disable once ArrangeTypeMemberModifiers class DatadogConfig { @@ -363,137 +349,9 @@ public static string ReplaceProperties(string yaml, ISession session) return yaml; } - private static int ReadAgentReceiverPort(string configFolder) - { - const int defaultPort = 8126; - var datadogYamlPath = Path.Combine(configFolder, "datadog.yaml"); - if (!File.Exists(datadogYamlPath)) - { - return defaultPort; - } - - try - { - using var input = new StreamReader(datadogYamlPath); - var deserializer = new DeserializerBuilder() - .IgnoreUnmatchedProperties() - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .Build(); - var cfg = deserializer.Deserialize(input); - return cfg?.ApmConfig?.ReceiverPort ?? defaultPort; - } - catch - { - return defaultPort; - } - } - - private static string ReadAiUsageChromeExtensionIdFromFile(string configPath) - { - if (!File.Exists(configPath)) - { - return ""; - } - - try - { - using var input = new StreamReader(configPath); - var deserializer = new DeserializerBuilder() - .IgnoreUnmatchedProperties() - .WithNamingConvention(UnderscoredNamingConvention.Instance) - .Build(); - var cfg = deserializer.Deserialize(input); - return cfg?.ChromeExtensionId?.Trim() ?? ""; - } - catch - { - return ""; - } - } - - private static string ReadAiUsageChromeExtensionId(string configFolder, ISession session) - { - var aiUsageNativeHostYamlPath = Path.Combine(configFolder, AiUsageNativeHostConfigName); - var extensionId = ReadAiUsageChromeExtensionIdFromFile(aiUsageNativeHostYamlPath); - if (!string.IsNullOrEmpty(extensionId)) - { - return extensionId; - } - - var aiUsageNativeHostExamplePath = Path.Combine(configFolder, $"{AiUsageNativeHostConfigName}.example"); - extensionId = ReadAiUsageChromeExtensionIdFromFile(aiUsageNativeHostExamplePath); - if (!string.IsNullOrEmpty(extensionId)) - { - return extensionId; - } - - session.Log( - $"No chrome_extension_id override found in {aiUsageNativeHostYamlPath} or {aiUsageNativeHostExamplePath}; " + - $"using fallback Chrome extension ID {Constants.FallbackAiUsageChromeExtensionId}."); - return Constants.FallbackAiUsageChromeExtensionId; - } - - private static string JsonEscape(string value) - { - return value - .Replace("\\", "\\\\") - .Replace("\"", "\\\""); - } - - private static void WriteAiUsageNativeMessagingManifest(string projectLocation, string configFolder, ISession session) - { - var manifestDir = Path.Combine(projectLocation, "bin", "agent", "dist"); - Directory.CreateDirectory(manifestDir); - - var hostExe = Path.Combine(projectLocation, "bin", "agent", "ai-usage-agent-native-host.exe"); - var extensionId = ReadAiUsageChromeExtensionId(configFolder, session); - var manifestPath = Path.Combine(manifestDir, $"{AiUsageNativeHostName}.json"); - foreach (var obsoleteHostName in ObsoleteAiUsageNativeHostNames) - { - var obsoleteManifestPath = Path.Combine(manifestDir, $"{obsoleteHostName}.json"); - try - { - if (File.Exists(obsoleteManifestPath)) - { - session.Log($"Deleting obsolete AI usage native messaging manifest \"{obsoleteManifestPath}\""); - File.Delete(obsoleteManifestPath); - } - } - catch (Exception e) - { - session.Log($"Failed to delete obsolete AI usage native messaging manifest \"{obsoleteManifestPath}\": {e}"); - } - } - - var manifest = "{\n" + - $" \"name\": \"{AiUsageNativeHostName}\",\n" + - " \"description\": \"Datadog AI usage native messaging host\",\n" + - $" \"path\": \"{JsonEscape(hostExe)}\",\n" + - " \"type\": \"stdio\",\n" + - " \"allowed_origins\": [\n" + - $" \"chrome-extension://{JsonEscape(extensionId)}/\"\n" + - " ]\n" + - "}\n"; - - File.WriteAllText(manifestPath, manifest); - } - - private static void GrantAiUsageNativeHostConfigReadAccess(string configPath) - { - var security = File.GetAccessControl(configPath); - // Chrome launches the native messaging host as the browser user, so all - // local users need read access to the host config under ProgramData. - security.AddAccessRule(new FileSystemAccessRule( - new SecurityIdentifier(WellKnownSidType.BuiltinUsersSid, null), - FileSystemRights.Read, - AccessControlType.Allow)); - File.SetAccessControl(configPath, security); - } - private static ActionResult WriteConfig(ISession session) { var configFolder = session.Property("APPLICATIONDATADIRECTORY"); - var projectLocation = session.Property("PROJECTLOCATION"); try { var copyFileFn = new Action(c => File.Copy(c + ".example", c)); @@ -523,31 +381,6 @@ private static ActionResult WriteConfig(ISession session) yaml = ReplaceProperties(yaml, session); - using var output = new StreamWriter(c); - output.Write(yaml); - }) - } - }) - .Concat(new[] - { - new - { - Path = AiUsageNativeHostConfigName, - CreateFn = new Action(c => - { - string yaml; - using (var input = new StreamReader(Path.Combine(configFolder, $"{AiUsageNativeHostConfigName}.example"))) - { - yaml = input.ReadToEnd(); - } - - var port = ReadAgentReceiverPort(configFolder); - yaml = Regex.Replace( - yaml, - "^[ #]*trace_agent_url:.*$", - $"trace_agent_url: \"http://127.0.0.1:{port}\"", - RegexOptions.Multiline); - using var output = new StreamWriter(c); output.Write(yaml); }) @@ -572,14 +405,7 @@ private static ActionResult WriteConfig(ISession session) { session.Log($"{configPath}.example doesn't exists."); } - - if (c.Path == AiUsageNativeHostConfigName && File.Exists(configPath)) - { - GrantAiUsageNativeHostConfigReadAccess(configPath); - } } - - WriteAiUsageNativeMessagingManifest(projectLocation, configFolder, session); } catch (Exception e) { diff --git a/tools/windows/DatadogAgentInstaller/CustomActions/Constants.cs b/tools/windows/DatadogAgentInstaller/CustomActions/Constants.cs index f0cb541a604d..6d4c22beb395 100644 --- a/tools/windows/DatadogAgentInstaller/CustomActions/Constants.cs +++ b/tools/windows/DatadogAgentInstaller/CustomActions/Constants.cs @@ -20,7 +20,5 @@ public class Constants // Flavor names public const string FipsFlavor = "fips"; public const string BaseFlavor = "base"; - - public const string FallbackAiUsageChromeExtensionId = "gkmbhgbippkmmmidcikijiblbagbjgjj"; } } diff --git a/tools/windows/DatadogAgentInstaller/CustomActions/CustomActions.csproj b/tools/windows/DatadogAgentInstaller/CustomActions/CustomActions.csproj index b09143bc6ebe..b02979c21c01 100644 --- a/tools/windows/DatadogAgentInstaller/CustomActions/CustomActions.csproj +++ b/tools/windows/DatadogAgentInstaller/CustomActions/CustomActions.csproj @@ -56,7 +56,6 @@ - diff --git a/tools/windows/DatadogAgentInstaller/CustomActions/InstallerHooksCustomAction.cs b/tools/windows/DatadogAgentInstaller/CustomActions/InstallerHooksCustomAction.cs index 069bfd805579..bdda176c6542 100644 --- a/tools/windows/DatadogAgentInstaller/CustomActions/InstallerHooksCustomAction.cs +++ b/tools/windows/DatadogAgentInstaller/CustomActions/InstallerHooksCustomAction.cs @@ -18,6 +18,8 @@ private static readonly (string MsiProperty, string EnvKey)[] InstallerHookEnvPr ("DD_INSTALLER_REGISTRY_USERNAME", "DD_INSTALLER_REGISTRY_USERNAME"), ("DD_INSTALLER_REGISTRY_PASSWORD", "DD_INSTALLER_REGISTRY_PASSWORD"), ("DD_OTELCOLLECTOR_ENABLED", "DD_OTELCOLLECTOR_ENABLED"), + // EUDM gate for the ai-usage extension (installed via installAgentExtensions) + ("DD_INFRASTRUCTURE_MODE", "DD_INFRASTRUCTURE_MODE"), }; private readonly ISession _session; diff --git a/tools/windows/DatadogAgentInstaller/WixSetup/Datadog Agent/AgentBinaries.cs b/tools/windows/DatadogAgentInstaller/WixSetup/Datadog Agent/AgentBinaries.cs index 99b893f73050..8762fb661caa 100644 --- a/tools/windows/DatadogAgentInstaller/WixSetup/Datadog Agent/AgentBinaries.cs +++ b/tools/windows/DatadogAgentInstaller/WixSetup/Datadog Agent/AgentBinaries.cs @@ -23,11 +23,6 @@ public class AgentBinaries public string Procmgr => $@"{_binSource}\dd-procmgr.exe"; public string AgentDataPlane => $@"{_binSource}\agent-data-plane.exe"; - // AI usage Chrome native messaging host (Rust). Shipped alongside ddtray.exe under bin\agent. - // Chrome discovers it via the machine-wide HKLM NativeMessagingHosts registration. - public string AiUsageAgentNativeHost => $@"{_binSource}\ai-usage-agent-native-host.exe"; - public Id AiUsageAgentNativeHostId => new("ai_usage_agent_native_host"); - public AgentBinaries(string binSource, string installerSource) { _binSource = binSource; diff --git a/tools/windows/DatadogAgentInstaller/WixSetup/Datadog Agent/AgentCustomActions.cs b/tools/windows/DatadogAgentInstaller/WixSetup/Datadog Agent/AgentCustomActions.cs index 53811ea929bc..aa112676d330 100644 --- a/tools/windows/DatadogAgentInstaller/WixSetup/Datadog Agent/AgentCustomActions.cs +++ b/tools/windows/DatadogAgentInstaller/WixSetup/Datadog Agent/AgentCustomActions.cs @@ -20,10 +20,6 @@ public class AgentCustomActions public ManagedAction SetupInstaller { get; set; } - public ManagedAction ConfigureAiUsageMonitorDesktopMonitor { get; } - - public ManagedAction RemoveAiUsageMonitorDesktopMonitor { get; } - public ManagedAction EnsureGeneratedFilesRemoved { get; } public ManagedAction WriteConfig { get; } @@ -355,21 +351,6 @@ public AgentCustomActions() .SetProperties( "PROJECTLOCATION=[PROJECTLOCATION], FLEET_INSTALL=[FLEET_INSTALL], DATABASE=[DATABASE]"); - ConfigureAiUsageMonitorDesktopMonitor = new CustomAction( - new Id(nameof(ConfigureAiUsageMonitorDesktopMonitor)), - CustomActions.ConfigureAiUsageMonitorDesktopMonitor, - Return.ignore, - When.After, - new Step(WriteConfig.Id), - Conditions.FirstInstall | Conditions.Upgrading | Conditions.Maintenance - ) - { - Execute = Execute.deferred, - Impersonate = false - } - .SetProperties( - "PROJECTLOCATION=[PROJECTLOCATION], APPLICATIONDATADIRECTORY=[APPLICATIONDATADIRECTORY]"); - // Cleanup leftover files on uninstall CleanupOnUninstall = new CustomAction( new Id(nameof(CleanupOnUninstall)), @@ -386,19 +367,6 @@ public AgentCustomActions() .SetProperties( "PROJECTLOCATION=[PROJECTLOCATION], APPLICATIONDATADIRECTORY=[APPLICATIONDATADIRECTORY]"); - RemoveAiUsageMonitorDesktopMonitor = new CustomAction( - new Id(nameof(RemoveAiUsageMonitorDesktopMonitor)), - CustomActions.RemoveAiUsageMonitorDesktopMonitor, - Return.ignore, - When.Before, - new Step(CleanupOnUninstall.Id), - Conditions.RemovingForUpgrade | Conditions.Uninstalling - ) - { - Execute = Execute.deferred, - Impersonate = false - }; - CleanupInstallDirAfterUninstall = new CustomAction( new Id(nameof(CleanupInstallDirAfterUninstall)), CustomActions.RemoveEmptyInstallDirAfterUninstall, @@ -851,7 +819,8 @@ public AgentCustomActions() "DD_INSTALLER_REGISTRY_AUTH=[DD_INSTALLER_REGISTRY_AUTH], " + "DD_INSTALLER_REGISTRY_USERNAME=[DD_INSTALLER_REGISTRY_USERNAME], " + "DD_INSTALLER_REGISTRY_PASSWORD=[DD_INSTALLER_REGISTRY_PASSWORD], " + - "DD_OTELCOLLECTOR_ENABLED=[DD_OTELCOLLECTOR_ENABLED]") + "DD_OTELCOLLECTOR_ENABLED=[DD_OTELCOLLECTOR_ENABLED], " + + "DD_INFRASTRUCTURE_MODE=[DD_INFRASTRUCTURE_MODE]") .HideTarget(true); ConfigureAutoLogger = new CustomAction( diff --git a/tools/windows/DatadogAgentInstaller/WixSetup/Datadog Agent/AgentInstaller.cs b/tools/windows/DatadogAgentInstaller/WixSetup/Datadog Agent/AgentInstaller.cs index 666e8fcdfb41..c95bbb7339b7 100644 --- a/tools/windows/DatadogAgentInstaller/WixSetup/Datadog Agent/AgentInstaller.cs +++ b/tools/windows/DatadogAgentInstaller/WixSetup/Datadog Agent/AgentInstaller.cs @@ -200,22 +200,6 @@ public Project Configure() new RegValue("InstallPath", "[PROJECTLOCATION]") { Win64 = true, AttributesDefinition = "KeyPath=yes" }, new RegValue("ConfigRoot", "[APPLICATIONDATADIRECTORY]") { Win64 = true, AttributesDefinition = "KeyPath=yes" } ) - { - Win64 = true - }, - new RegKey( - _agentFeatures.MainApplication, - RegistryHive.LocalMachine, @"Software\Google\Chrome\NativeMessagingHosts\com.datadoghq.ai_usage_agent.native_host", - new RegValue("", @"[AGENT]dist\com.datadoghq.ai_usage_agent.native_host.json") { Win64 = true, AttributesDefinition = "KeyPath=yes" } - ) - { - Win64 = true - }, - new RegKey( - _agentFeatures.MainApplication, - RegistryHive.LocalMachine, @"Software\WOW6432Node\Google\Chrome\NativeMessagingHosts\com.datadoghq.ai_usage_agent.native_host", - new RegValue("", @"[AGENT]dist\com.datadoghq.ai_usage_agent.native_host.json") { Win64 = true, AttributesDefinition = "KeyPath=yes" } - ) { Win64 = true } @@ -718,10 +702,6 @@ private Dir CreateBinFolder() agentBinDir.AddFile(new WixSharp.File(_agentBinaries.AgentDataPlane)); - // AI usage Chrome native messaging host (Rust). Plain non-service file in bin\agent. - // Explicit Id only on the .exe so future custom actions can reference it via [#ai_usage_agent_native_host]. - agentBinDir.AddFile(new WixSharp.File(_agentBinaries.AiUsageAgentNativeHostId, _agentBinaries.AiUsageAgentNativeHost)); - var targetBinFolder = new Dir(new Id("BIN"), "bin", new WixSharp.File(_agentBinaries.Agent, agentService), // Temporary binary for extracting the embedded Python - will be deleted