diff --git a/docs/agent/endpoint-migration.md b/docs/agent/endpoint-migration.md
new file mode 100644
index 00000000..54fda3c3
--- /dev/null
+++ b/docs/agent/endpoint-migration.md
@@ -0,0 +1,88 @@
+---
+type: Concept
+title: Agent Endpoint Migration — Config-Channel Re-Home
+description: How the backend re-homes agents to a new API base URL (backend move or per-tenant region move) via MigrateToApiBaseUrl on the agent config response, and why HTTP redirects were rejected.
+resource: /src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/EndpointMigration.cs
+tags:
+ - agent
+ - backend
+ - config
+ - migration
+ - security
+timestamp: 2026-07-18T00:00:00+02:00
+---
+
+# Problem
+
+The agent's API base URL is compiled into the binary (`Constants.ApiBaseUrl`). No DNS/domain
+indirection is possible in front of the API: mTLS client-cert auth does not survive a reverse
+proxy (Front Door), and Flex Consumption custom-domain TLS is broken. A backend move (like the
+2026-07-18 WEU→GWC subscription migration) therefore hard-cuts every deployed agent still
+pointing at the old hostname. In-flight agents self-terminate safely (local MaxLifetime watchdog
++ CleanupService), but their sessions are lost and a per-tenant region move (EU→US) has no
+mechanism at all.
+
+# Mechanism
+
+`AgentConfigResponse.MigrateToApiBaseUrl` — served on `GET /api/agent/config` (incl. the
+bootstrap wrapper), the same control channel as the kill switch, fetched at every agent
+process start.
+
+Agent flow (`Program.RunAgent`, Phase 4):
+
+1. Config fetched from the compiled-in (old) base URL.
+2. Kill-switch check runs FIRST — **kill wins over migrate**.
+3. `AgentRuntimeConfig.Resolve` detects the migration signal directly after the fetch and
+ returns early, BEFORE merge / binary-integrity / bootstrap-cleanup — none of those side
+ effects may run against a config from the backend being abandoned (a migration stub may
+ advertise stale agent hashes; a spurious `IntegrityCheckFailed` + force-update would be
+ harmful).
+4. `Program.RunAgent` swaps `AgentConfiguration.ApiBaseUrl`, disposes the old client stack,
+ rebuilds Phase 3 (auth clients) and re-runs Phase 4 against the new URL —
+ `allowEndpointMigration: false`, so the new backend's config **cannot chain a second hop**.
+5. Kill-switch check runs again on the NEW backend's config; startup continues normally
+ (Phase 5+ builds mTLS/telemetry clients from the updated base URL).
+
+# Hardening (mirrors the kill switch)
+
+* **Live fetch only** — the on-disk config cache strips `MigrateToApiBaseUrl` on write AND
+ read (`RemoteConfigService.CacheConfig` / `LoadCachedConfig`); `EndpointMigration.ResolveTarget`
+ re-checks `LastFetchOutcome == Succeeded` as defence-in-depth. A planted cache file can
+ never re-home an agent.
+* **Allowlist validated on BOTH sides** — `AgentEndpointMigrationRules` (Shared, single
+ source): https only, host must end in `.azurewebsites.net` on a label boundary, no
+ path/query/userinfo/port. The backend refuses to serve an invalid admin value
+ (`AgentMigrateRejected` warning); the agent refuses to honour one even if the backend was
+ compromised.
+* **One hop, no loops** — second Resolve pass never migrates; a target equal to the current
+ base URL is a no-op (steady state during the migration window).
+
+# Operation
+
+Global admin settings (`AdminConfiguration`, admin portal → Global Settings):
+
+* `AgentMigrateApiBaseUrl` — global target, set on the backend being **abandoned**; clear it
+ after the migration window.
+* `AgentMigrateTenantOverridesJson` — JSON object `tenantId → URL` for per-tenant moves.
+ An entry with an **empty string** pins that tenant against the global target (staged rollout).
+ Overrides win over the global value.
+
+Delivery evidence: every served target logs `AgentMigrateServed` (Warning — reaches App
+Insights) with tenant, serial, agent version and target. Agent-side, the re-home is logged as
+`Endpoint migration: backend re-homed this agent from … to …`.
+
+Reach limitation: agents that cannot complete an authenticated config fetch (e.g. cert-broken
+devices) never see the signal — they fall back to the local MaxLifetime/emergency-break
+lifecycle. During a migration window the old backend must keep running as a serving stub;
+keeping the old `*.azurewebsites.net` name parked afterwards also prevents hostname
+re-registration by third parties while stragglers drain.
+
+# Citations
+
+* `src/Shared/AutopilotMonitor.Shared/Services/AgentEndpointMigrationRules.cs` — shared validation rules
+* `src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs` — wire field
+* `src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs` — `ResolveMigrateTarget` + serving (ConfigVersion 33)
+* `src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/EndpointMigration.cs` — agent-side evaluation
+* `src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.cs` — early return before merge/integrity
+* `src/Agent/AutopilotMonitor.Agent.V2/Program.cs` — one-hop rebuild in `RunAgent`
+* Tests: `AgentEndpointMigrationTests` (backend), `EndpointMigrationTests` + `RemoteConfigServiceCacheHardeningTests` (agent)
diff --git a/docs/index.md b/docs/index.md
index cdb1ea71..5025491e 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -17,6 +17,7 @@ https://docs.autopilotmonitor.com (separate repository).
* [V2 Agent](agent/index.md) - How the on-device agent works: [runtime overview](agent/overview.md), [decision engine](agent/decision-engine.md) (reducer, signals, completion arms), [logs & persistence](agent/logs-and-persistence.md) (signal log, snapshot, crash recovery), [Hello wizard un-skip](agent/hello-wizard-unskip.md) (why a single "WHfB disabled" read never decides completion), [Autopilot ZTD diagnostics](agent/autopilot-ztd-diagnostics.md) (event IDs, diagnostic registry, endpoints, error-code map).
* [Build Counter Blob](agent/build-counter-blob.md) - How agent build numbers are minted: shared counter blob with ETag-CAS so local and CI builds never collide; CI additionally attests build provenance (Sigstore).
+* [Agent Endpoint Migration](agent/endpoint-migration.md) - How the backend re-homes agents to a new API base URL via the config channel (backend move or per-tenant region move): live-fetch-only, allowlist-validated on both sides, one hop, kill wins.
# Security & Identity
diff --git a/docs/log.md b/docs/log.md
index 77870871..cc7d6b41 100644
--- a/docs/log.md
+++ b/docs/log.md
@@ -1,5 +1,9 @@
# Log
+## 2026-07-18
+
+* **Creation**: Added `agent/endpoint-migration.md` — config-channel agent re-home (`MigrateToApiBaseUrl` on `GET agent/config`, ConfigVersion 33): backend or per-tenant API-URL moves without DNS indirection (impossible due to mTLS/Flex-TLS). Live-fetch-only with cache strip, `AgentEndpointMigrationRules` allowlist validated on both sides, one hop, kill-switch wins. Built as the lesson from the 2026-07-18 WEU→GWC cutover, which hard-cut all in-flight agents.
+
## 2026-07-17
* **Creation**: Added `agent/build-counter-blob.md` — agent build numbers now come from a shared public counter blob (`buildcounter-v{N}.txt`, reserve-before-build with ETag-CAS) so local `build.ps1` and CI `build-agent.yml` builds can never mint the same number. Documents the CI parity fixes (SummaryDialog.exe.config in the ZIP, EXE-vs-manifest version check, hash-oracle update gated on stable cutover) and the Sigstore provenance attestation on CI-built ZIPs.
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/EndpointMigrationTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/EndpointMigrationTests.cs
new file mode 100644
index 00000000..e7d34c4b
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/EndpointMigrationTests.cs
@@ -0,0 +1,99 @@
+using AutopilotMonitor.Agent.V2.Core.Configuration;
+using AutopilotMonitor.Shared.Models;
+using Xunit;
+
+namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
+{
+ ///
+ /// Pins the agent-side endpoint-migration contract ():
+ /// only a LIVE fetch is honoured, the target must pass the shared allowlist rules even
+ /// though the backend already validated it, and serving the agent its own current URL is
+ /// a no-op — the exact mirror of the kill-switch live-fetch hardening.
+ ///
+ public sealed class EndpointMigrationTests
+ {
+ private const string CurrentUrl = "https://autopilotmonitor-api-eu.azurewebsites.net";
+ private const string TargetUrl = "https://autopilotmonitor-api-us.azurewebsites.net";
+
+ private static AgentConfigResponse Config(string migrateUrl) =>
+ new AgentConfigResponse { MigrateToApiBaseUrl = migrateUrl };
+
+ [Fact]
+ public void Live_fetch_with_valid_differing_target_migrates()
+ {
+ var target = EndpointMigration.ResolveTarget(
+ Config(TargetUrl), RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null);
+
+ Assert.Equal(TargetUrl, target);
+ }
+
+ [Fact]
+ public void Target_is_normalized_to_lowercase_host_without_trailing_slash()
+ {
+ var target = EndpointMigration.ResolveTarget(
+ Config("https://AutopilotMonitor-API-US.azurewebsites.net/"),
+ RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null);
+
+ Assert.Equal(TargetUrl, target);
+ }
+
+ [Theory]
+ [InlineData(RemoteConfigFetchOutcome.FromCache)]
+ [InlineData(RemoteConfigFetchOutcome.UsedDefaults)]
+ [InlineData(RemoteConfigFetchOutcome.NotAttempted)]
+ public void Non_live_fetch_is_never_honoured(RemoteConfigFetchOutcome outcome)
+ {
+ Assert.Null(EndpointMigration.ResolveTarget(
+ Config(TargetUrl), outcome, CurrentUrl, logger: null));
+ }
+
+ [Fact]
+ public void Null_config_and_empty_signal_are_noops()
+ {
+ Assert.Null(EndpointMigration.ResolveTarget(
+ null, RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null));
+ Assert.Null(EndpointMigration.ResolveTarget(
+ Config(null), RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null));
+ Assert.Null(EndpointMigration.ResolveTarget(
+ Config(" "), RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null));
+ }
+
+ [Fact]
+ public void Target_equal_to_current_url_is_a_noop()
+ {
+ // Steady state during a migration window: agent already runs on the target
+ // (case/trailing-slash differences must not cause a rebuild loop).
+ Assert.Null(EndpointMigration.ResolveTarget(
+ Config(CurrentUrl), RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null));
+ Assert.Null(EndpointMigration.ResolveTarget(
+ Config(CurrentUrl.ToUpperInvariant() + "/"), RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null));
+ }
+
+ [Theory]
+ [InlineData("http://autopilotmonitor-api-us.azurewebsites.net")] // https only
+ [InlineData("https://attacker.example.com")] // host not allowlisted
+ [InlineData("https://evil-azurewebsites.net")] // suffix must sit on a label boundary
+ [InlineData("https://.azurewebsites.net")] // empty host prefix
+ [InlineData("https://api.azurewebsites.net:8443")] // non-default port
+ [InlineData("https://user:pw@api.azurewebsites.net")] // userinfo
+ [InlineData("https://api.azurewebsites.net/some/path")] // base URL only
+ [InlineData("https://api.azurewebsites.net?x=1")] // no query
+ [InlineData("not a url")]
+ public void Invalid_targets_are_rejected(string candidate)
+ {
+ Assert.Null(EndpointMigration.ResolveTarget(
+ Config(candidate), RemoteConfigFetchOutcome.Succeeded, CurrentUrl, logger: null));
+ }
+
+ [Fact]
+ public void Dev_api_url_override_still_migrates_to_valid_target()
+ {
+ // The running base URL may be a non-allowlisted dev override (--api-url); the
+ // comparison falls back to plain normalization and the migration still applies.
+ var target = EndpointMigration.ResolveTarget(
+ Config(TargetUrl), RemoteConfigFetchOutcome.Succeeded, "http://localhost:7071", logger: null);
+
+ Assert.Equal(TargetUrl, target);
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceCacheHardeningTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceCacheHardeningTests.cs
index 36b276b7..115d80dc 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceCacheHardeningTests.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Configuration/RemoteConfigServiceCacheHardeningTests.cs
@@ -62,6 +62,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
DeviceBlocked = true,
DeviceKillSignal = true,
UnblockAt = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ MigrateToApiBaseUrl = "https://attacker.azurewebsites.net",
};
probe.DoCache(config);
@@ -76,9 +77,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.False(persisted.DeviceBlocked);
Assert.False(persisted.DeviceKillSignal);
Assert.Null(persisted.UnblockAt);
+ Assert.Null(persisted.MigrateToApiBaseUrl);
- // The attacker EXE hash must not appear anywhere in the persisted bytes.
+ // The attacker EXE hash / re-home host must not appear anywhere in the persisted bytes.
Assert.DoesNotContain("deadbeef", raw);
+ Assert.DoesNotContain("attacker.azurewebsites.net", raw);
// Benign fields survive the strip.
Assert.Equal(77, persisted.ConfigVersion);
@@ -92,6 +95,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
Assert.True(config.DeviceBlocked);
Assert.True(config.DeviceKillSignal);
Assert.Equal(new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc), config.UnblockAt);
+ Assert.Equal("https://attacker.azurewebsites.net", config.MigrateToApiBaseUrl);
}
// ── Read path: LoadCachedConfig strips a planted (attacker) cache file ─
@@ -103,6 +107,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
[InlineData("DeviceBlocked")]
[InlineData("DeviceKillSignal")]
[InlineData("UnblockAt")]
+ [InlineData("MigrateToApiBaseUrl")]
public void LoadCachedConfig_strips_planted_security_sensitive_field(string field)
{
var probe = NewProbe();
@@ -119,6 +124,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
DeviceBlocked = true,
DeviceKillSignal = true,
UnblockAt = new DateTime(2099, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+ MigrateToApiBaseUrl = "https://attacker.azurewebsites.net",
};
File.WriteAllText(_cachePath, JsonConvert.SerializeObject(attacker, Formatting.Indented));
@@ -145,6 +151,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Configuration
case "UnblockAt":
Assert.Null(loaded.UnblockAt);
break;
+ case "MigrateToApiBaseUrl":
+ Assert.Null(loaded.MigrateToApiBaseUrl);
+ break;
default:
throw new InvalidOperationException($"unknown field {field}");
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/EndpointMigration.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/EndpointMigration.cs
new file mode 100644
index 00000000..e364008a
--- /dev/null
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/EndpointMigration.cs
@@ -0,0 +1,57 @@
+using AutopilotMonitor.Agent.V2.Core.Logging;
+using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Services;
+
+namespace AutopilotMonitor.Agent.V2.Core.Configuration
+{
+ ///
+ /// Agent-side evaluation of the config-channel endpoint migration signal
+ /// (). Mirrors the kill-switch
+ /// hardening: only a LIVE fetch is honoured (the on-disk config cache strips the field on
+ /// write and read, and this check re-validates the outcome as defence-in-depth), and the
+ /// target must pass even though the backend
+ /// already validated it — a compromised or misconfigured backend must not be able to
+ /// re-home agents to an arbitrary host.
+ ///
+ public static class EndpointMigration
+ {
+ ///
+ /// Returns the normalized migration target when the config carries a valid, live-fetched
+ /// re-home signal that differs from ; otherwise null.
+ /// Never throws — every rejection is logged and degrades to "no migration".
+ ///
+ public static string ResolveTarget(
+ AgentConfigResponse remoteConfig,
+ RemoteConfigFetchOutcome fetchOutcome,
+ string currentBaseUrl,
+ AgentLogger logger)
+ {
+ if (remoteConfig == null || string.IsNullOrWhiteSpace(remoteConfig.MigrateToApiBaseUrl))
+ return null;
+
+ if (fetchOutcome != RemoteConfigFetchOutcome.Succeeded)
+ {
+ logger?.Warning(
+ $"Endpoint migration signal ignored — config source is {fetchOutcome}, not a live fetch.");
+ return null;
+ }
+
+ if (!AgentEndpointMigrationRules.IsEffectiveMigration(
+ remoteConfig.MigrateToApiBaseUrl, currentBaseUrl, out var target))
+ {
+ // Either the target failed validation (scheme/host allowlist) or it equals the
+ // URL the agent is already using (steady state during a migration window once
+ // the new backend's own config echoes nothing).
+ if (target == null && !AgentEndpointMigrationRules.TryNormalizeTarget(
+ remoteConfig.MigrateToApiBaseUrl, out _))
+ {
+ logger?.Warning(
+ $"Endpoint migration signal REJECTED — target failed validation: '{remoteConfig.MigrateToApiBaseUrl}'");
+ }
+ return null;
+ }
+
+ return target;
+ }
+ }
+}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs
index 22468e0a..b63f4815 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Configuration/RemoteConfigService.cs
@@ -249,18 +249,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
// - DeviceBlocked/DeviceKillSignal/UnblockAt: kill is only honoured from a
// live fetch — a planted cached kill would self-destruct every future
// session, a stale cached kill would outlive the admin removing the rule
+ // - MigrateToApiBaseUrl: endpoint re-home is only honoured from a live
+ // fetch — a planted cached URL would silently redirect every future
+ // session's telemetry to an attacker-chosen host
var liveUnrestricted = config.UnrestrictedMode;
var liveAllowDowngrade = config.AllowAgentDowngrade;
var liveExeHash = config.LatestAgentExeSha256;
var liveBlocked = config.DeviceBlocked;
var liveKill = config.DeviceKillSignal;
var liveUnblockAt = config.UnblockAt;
+ var liveMigrateUrl = config.MigrateToApiBaseUrl;
config.UnrestrictedMode = false;
config.AllowAgentDowngrade = false;
config.LatestAgentExeSha256 = null;
config.DeviceBlocked = false;
config.DeviceKillSignal = false;
config.UnblockAt = null;
+ config.MigrateToApiBaseUrl = null;
var json = JsonConvert.SerializeObject(config, Formatting.Indented);
config.UnrestrictedMode = liveUnrestricted;
config.AllowAgentDowngrade = liveAllowDowngrade;
@@ -268,6 +273,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
config.DeviceBlocked = liveBlocked;
config.DeviceKillSignal = liveKill;
config.UnblockAt = liveUnblockAt;
+ config.MigrateToApiBaseUrl = liveMigrateUrl;
File.WriteAllText(_cacheFilePath, json);
_logger.Debug("Remote config cached to disk");
@@ -299,6 +305,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Configuration
config.DeviceBlocked = false;
config.DeviceKillSignal = false;
config.UnblockAt = null;
+ config.MigrateToApiBaseUrl = null;
}
return config;
}
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
index a909c59e..f8b9de7d 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Program.cs
@@ -213,6 +213,8 @@ namespace AutopilotMonitor.Agent.V2
// Control-channel kill-switch: a live-fetch DeviceKillSignal terminates the agent
// BEFORE mTLS/registration spin-up. Covers agents the telemetry-channel kill can
// never reach (paused drain, empty spool) at their next process start / boot.
+ // Checked BEFORE any endpoint migration — a kill from the current backend is
+ // terminal, not re-homed.
if (CheckConfigKillSignal(
runtimeConfig.RemoteConfig,
runtimeConfig.RemoteConfigService.LastFetchOutcome,
@@ -223,6 +225,37 @@ namespace AutopilotMonitor.Agent.V2
return 0;
}
+ // Endpoint migration (config-channel re-home): the backend served a validated
+ // MigrateToApiBaseUrl on a live fetch. Rebuild Phase 3 clients against the new base
+ // URL and re-run Phase 4 there (single hop — the second Resolve cannot migrate
+ // again), then re-check the kill signal against the NEW backend's config.
+ if (runtimeConfig.PendingMigrationTarget != null)
+ {
+ logger.Warning(
+ $"Endpoint migration: backend re-homed this agent from {bootstrap.AgentConfig.ApiBaseUrl} " +
+ $"to {runtimeConfig.PendingMigrationTarget} — rebuilding backend clients.");
+
+ bootstrap.AgentConfig.ApiBaseUrl = runtimeConfig.PendingMigrationTarget;
+
+ // Best-effort disposal of the old-URL client stack before it goes out of scope.
+ try { auth.BackendApiClient?.Dispose(); } catch { }
+
+ auth = Runtime.BackendClientFactory.BuildAuthClients(bootstrap.AgentConfig, GetAgentVersion(), logger);
+ runtimeConfig = Runtime.AgentRuntimeConfig.Resolve(
+ bootstrap.AgentConfig, auth, GetAgentVersion(), consoleMode, logger,
+ allowEndpointMigration: false);
+
+ if (CheckConfigKillSignal(
+ runtimeConfig.RemoteConfig,
+ runtimeConfig.RemoteConfigService.LastFetchOutcome,
+ dataDirectory, stateSubdir,
+ () => new CleanupService(bootstrap.AgentConfig, logger),
+ logger, consoleMode))
+ {
+ return 0;
+ }
+ }
+
// Phase 5 (mTLS HttpClient + BackendTelemetryUploader) is encapsulated in
// BackendClientFactory. Construction failures map to V1-parity exit codes
// (4 = mTLS, 5 = uploader). See Runtime/BackendClientFactory.cs.
diff --git a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.cs b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.cs
index 17cb8ae8..59badec9 100644
--- a/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.cs
+++ b/src/Agent/AutopilotMonitor.Agent.V2/Runtime/AgentRuntimeConfig.cs
@@ -32,7 +32,8 @@ namespace AutopilotMonitor.Agent.V2.Runtime
BackendAuthBundle auth,
string agentVersion,
bool consoleMode,
- AgentLogger logger)
+ AgentLogger logger,
+ bool allowEndpointMigration = true)
{
if (agentConfig == null) throw new ArgumentNullException(nameof(agentConfig));
if (auth == null) throw new ArgumentNullException(nameof(auth));
@@ -49,6 +50,25 @@ namespace AutopilotMonitor.Agent.V2.Runtime
.FetchConfigAsync(retryOnTransientErrors: true)
.GetAwaiter().GetResult();
+ // Endpoint migration (config-channel re-home): decided BEFORE merge / binary-
+ // integrity / bootstrap-cleanup so none of those side effects run against a config
+ // from the backend being abandoned — a migration stub may advertise stale agent
+ // hashes, and a spurious IntegrityCheckFailed + force-update against them would be
+ // actively harmful. RunAgent rebuilds Phase 3 clients on the new base URL and calls
+ // Resolve again with allowEndpointMigration:false (single hop — the new backend's
+ // config cannot chain another migration).
+ if (allowEndpointMigration)
+ {
+ var migrationTarget = EndpointMigration.ResolveTarget(
+ remoteConfig, remoteConfigService.LastFetchOutcome, agentConfig.ApiBaseUrl, logger);
+ if (migrationTarget != null)
+ {
+ return new RuntimeConfigBundle(
+ remoteConfigService, remoteConfig, mergeResult: null,
+ pendingMigrationTarget: migrationTarget);
+ }
+ }
+
// Project remote tenant-controlled knobs onto the runtime AgentConfiguration so that
// downstream consumers (CleanupService, SummaryDialogLauncher, StartupEnvironmentProbes,
// DiagnosticsPackageService, EnrollmentTerminationHandler, the logger, the watchdog)
@@ -71,7 +91,7 @@ namespace AutopilotMonitor.Agent.V2.Runtime
TryCleanupBootstrapConfig(agentConfig, agentVersion, logger);
- return new RuntimeConfigBundle(remoteConfigService, remoteConfig, configMergeResult);
+ return new RuntimeConfigBundle(remoteConfigService, remoteConfig, configMergeResult, pendingMigrationTarget: null);
}
///
@@ -145,21 +165,30 @@ namespace AutopilotMonitor.Agent.V2.Runtime
/// rotate_config ServerAction callback), the fetched
/// and the diff snapshot (used by the
/// EmitUnrestrictedModeAuditIfChanged lifecycle hook).
+ ///
+ /// When is non-null the bundle is a partial result:
+ /// the fetch found a live endpoint-migration signal and Resolve returned before merge /
+ /// integrity / bootstrap-cleanup ( is null). RunAgent must
+ /// rebuild the backend clients on the target URL and Resolve again.
+ ///
///
internal sealed class RuntimeConfigBundle
{
public RemoteConfigService RemoteConfigService { get; }
public AgentConfigResponse RemoteConfig { get; }
public RemoteConfigMergeResult MergeResult { get; }
+ public string PendingMigrationTarget { get; }
public RuntimeConfigBundle(
RemoteConfigService remoteConfigService,
AgentConfigResponse remoteConfig,
- RemoteConfigMergeResult mergeResult)
+ RemoteConfigMergeResult mergeResult,
+ string pendingMigrationTarget = null)
{
RemoteConfigService = remoteConfigService;
RemoteConfig = remoteConfig;
MergeResult = mergeResult;
+ PendingMigrationTarget = pendingMigrationTarget;
}
}
}
diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AgentEndpointMigrationTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AgentEndpointMigrationTests.cs
new file mode 100644
index 00000000..e87480b3
--- /dev/null
+++ b/src/Backend/AutopilotMonitor.Functions.Tests/AgentEndpointMigrationTests.cs
@@ -0,0 +1,160 @@
+using AutopilotMonitor.Functions.Functions.Config;
+using AutopilotMonitor.Shared.Models;
+using AutopilotMonitor.Shared.Services;
+using Xunit;
+
+namespace AutopilotMonitor.Functions.Tests;
+
+///
+/// Pins the endpoint-migration serving contract: the shared allowlist rules
+/// (, used verbatim by the agent too) and the
+/// backend-side per-tenant resolution ( —
+/// tenant override wins, empty override pins, invalid values are never served).
+///
+public class AgentEndpointMigrationTests
+{
+ private const string TenantA = "11111111-2222-3333-4444-555555555555";
+ private const string TenantB = "66666666-7777-8888-9999-000000000000";
+ private const string UsUrl = "https://autopilotmonitor-api-us.azurewebsites.net";
+ private const string EuUrl = "https://autopilotmonitor-api-eu.azurewebsites.net";
+
+ // ── Shared validation rules ─────────────────────────────────────────────
+
+ [Theory]
+ [InlineData("https://autopilotmonitor-api-us.azurewebsites.net", "https://autopilotmonitor-api-us.azurewebsites.net")]
+ [InlineData("https://AutopilotMonitor-API-US.AzureWebsites.NET/", "https://autopilotmonitor-api-us.azurewebsites.net")]
+ [InlineData(" https://x.azurewebsites.net ", "https://x.azurewebsites.net")]
+ public void TryNormalizeTarget_accepts_and_normalizes(string candidate, string expected)
+ {
+ Assert.True(AgentEndpointMigrationRules.TryNormalizeTarget(candidate, out var normalized));
+ Assert.Equal(expected, normalized);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData("http://x.azurewebsites.net")] // https only
+ [InlineData("https://attacker.example.com")] // not allowlisted
+ [InlineData("https://evil-azurewebsites.net")] // label-boundary bypass attempt
+ [InlineData("https://.azurewebsites.net")] // bare suffix
+ [InlineData("https://x.azurewebsites.net:8443")] // non-default port
+ [InlineData("https://u:p@x.azurewebsites.net")] // userinfo
+ [InlineData("https://x.azurewebsites.net/api")] // path
+ [InlineData("https://x.azurewebsites.net?a=1")] // query
+ [InlineData("https://x.azurewebsites.net#frag")] // fragment
+ [InlineData("ftp://x.azurewebsites.net")]
+ [InlineData("not a url")]
+ public void TryNormalizeTarget_rejects(string? candidate)
+ {
+ Assert.False(AgentEndpointMigrationRules.TryNormalizeTarget(candidate!, out var normalized));
+ Assert.Null(normalized);
+ }
+
+ [Fact]
+ public void TryNormalizeTarget_rejects_overlong_candidate()
+ {
+ var candidate = "https://" + new string('a', AgentEndpointMigrationRules.MaxTargetLength) + ".azurewebsites.net";
+ Assert.False(AgentEndpointMigrationRules.TryNormalizeTarget(candidate, out _));
+ }
+
+ [Fact]
+ public void IsEffectiveMigration_rejects_target_equal_to_current()
+ {
+ Assert.False(AgentEndpointMigrationRules.IsEffectiveMigration(EuUrl, EuUrl + "/", out var target));
+ Assert.Null(target);
+
+ Assert.True(AgentEndpointMigrationRules.IsEffectiveMigration(UsUrl, EuUrl, out target));
+ Assert.Equal(UsUrl, target);
+ }
+
+ // ── Backend resolution: global vs per-tenant override ──────────────────
+
+ [Fact]
+ public void No_configuration_resolves_to_null()
+ {
+ var config = new AdminConfiguration();
+
+ Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected));
+ Assert.Null(rejected);
+ }
+
+ [Fact]
+ public void Global_target_applies_to_every_tenant()
+ {
+ var config = new AdminConfiguration { AgentMigrateApiBaseUrl = UsUrl };
+
+ Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
+ Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _));
+ }
+
+ [Fact]
+ public void Tenant_override_wins_over_global()
+ {
+ var config = new AdminConfiguration
+ {
+ AgentMigrateApiBaseUrl = UsUrl,
+ AgentMigrateTenantOverridesJson = $"{{\"{TenantA}\": \"{EuUrl}\"}}"
+ };
+
+ Assert.Equal(EuUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
+ Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _));
+ }
+
+ [Fact]
+ public void Empty_tenant_override_pins_tenant_against_global_target()
+ {
+ var config = new AdminConfiguration
+ {
+ AgentMigrateApiBaseUrl = UsUrl,
+ AgentMigrateTenantOverridesJson = $"{{\"{TenantA}\": \"\"}}"
+ };
+
+ Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected));
+ Assert.Null(rejected); // pinned, not rejected
+ Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantB, out _));
+ }
+
+ [Fact]
+ public void Tenant_override_key_is_case_insensitive()
+ {
+ var config = new AdminConfiguration
+ {
+ AgentMigrateTenantOverridesJson = $"{{\"{TenantA.ToUpperInvariant()}\": \"{UsUrl}\"}}"
+ };
+
+ Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
+ }
+
+ [Fact]
+ public void Invalid_configured_target_is_never_served_and_reported_as_rejected()
+ {
+ var config = new AdminConfiguration { AgentMigrateApiBaseUrl = "https://attacker.example.com" };
+
+ Assert.Null(GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out var rejected));
+ Assert.Equal("https://attacker.example.com", rejected);
+ }
+
+ [Fact]
+ public void Malformed_overrides_json_degrades_to_global_target()
+ {
+ var config = new AdminConfiguration
+ {
+ AgentMigrateApiBaseUrl = UsUrl,
+ AgentMigrateTenantOverridesJson = "{not json"
+ };
+
+ Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
+ }
+
+ [Fact]
+ public void Served_target_is_normalized()
+ {
+ var config = new AdminConfiguration
+ {
+ AgentMigrateApiBaseUrl = "https://AutopilotMonitor-API-US.azurewebsites.net/"
+ };
+
+ Assert.Equal(UsUrl, GetAgentConfigFunction.ResolveMigrateTarget(config, TenantA, out _));
+ }
+}
diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
index 3a0598cb..7a7ab16c 100644
--- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
+++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs
@@ -582,7 +582,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "FeedbackMinTenantAgeDays", config.FeedbackMinTenantAgeDays },
{ "FeedbackCooldownDays", config.FeedbackCooldownDays },
// MCP access control
- { "McpAccessPolicy", config.McpAccessPolicy ?? "WhitelistOnly" }
+ { "McpAccessPolicy", config.McpAccessPolicy ?? "WhitelistOnly" },
+ // Agent endpoint migration (config-channel re-home)
+ { "AgentMigrateApiBaseUrl", config.AgentMigrateApiBaseUrl ?? string.Empty },
+ { "AgentMigrateTenantOverridesJson", config.AgentMigrateTenantOverridesJson ?? string.Empty }
};
return entity;
@@ -643,7 +646,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
FeedbackMinTenantAgeDays = entity.GetInt32("FeedbackMinTenantAgeDays") ?? 14,
FeedbackCooldownDays = entity.GetInt32("FeedbackCooldownDays") ?? 60,
// MCP access control
- McpAccessPolicy = entity.GetString("McpAccessPolicy") ?? "WhitelistOnly"
+ McpAccessPolicy = entity.GetString("McpAccessPolicy") ?? "WhitelistOnly",
+ // Agent endpoint migration (config-channel re-home)
+ AgentMigrateApiBaseUrl = entity.GetString("AgentMigrateApiBaseUrl") ?? string.Empty,
+ AgentMigrateTenantOverridesJson = entity.GetString("AgentMigrateTenantOverridesJson") ?? string.Empty
};
}
diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs
index 42b70fbd..e621d9b3 100644
--- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs
+++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs
@@ -157,6 +157,39 @@ namespace AutopilotMonitor.Functions.Functions.Config
return (line.ZipSha256, line.ExeSha256);
}
+ ///
+ /// Resolves the endpoint-migration target for a tenant: per-tenant override wins over
+ /// the global value; an override entry with an empty value pins the tenant (no
+ /// migration even while the global target is set — staged rollout). The winning value
+ /// is validated against ;
+ /// invalid values resolve to null (fail-safe: better to strand an agent on the old
+ /// backend than to serve a broken or non-allowlisted URL).
+ ///
+ internal static string? ResolveMigrateTarget(
+ AutopilotMonitor.Shared.Models.AdminConfiguration adminConfig,
+ string tenantId,
+ out string? rejectedCandidate)
+ {
+ rejectedCandidate = null;
+ string? candidate = adminConfig.AgentMigrateApiBaseUrl;
+
+ var overrides = adminConfig.GetAgentMigrateTenantOverrides();
+ if (!string.IsNullOrEmpty(tenantId) && overrides.TryGetValue(tenantId, out var tenantTarget))
+ candidate = tenantTarget; // empty string = pinned, handled below
+
+ if (string.IsNullOrWhiteSpace(candidate))
+ return null;
+
+ if (AutopilotMonitor.Shared.Services.AgentEndpointMigrationRules
+ .TryNormalizeTarget(candidate, out var normalized))
+ {
+ return normalized;
+ }
+
+ rejectedCandidate = candidate;
+ return null;
+ }
+
///
/// Core config logic: fetch tenant + admin config, gather rules, IME patterns.
/// Called by both the cert-auth Run() method and the bootstrap wrapper.
@@ -215,10 +248,29 @@ namespace AutopilotMonitor.Functions.Functions.Config
// so agent code is unchanged across all major lines.
var (latestAgentSha256, latestAgentExeSha256) = SelectAgentHashesForClient(req, adminConfig);
+ // Endpoint migration on the control channel: serve the (validated) re-home target
+ // so agents still bound to this backend's compiled-in URL move themselves to the
+ // new deployment at their next start. LogWarning (not Information) because worker
+ // logs below Warning never reach App Insights — this line is the delivery evidence
+ // during a migration window.
+ var migrateTarget = ResolveMigrateTarget(adminConfig, tenantId, out var rejectedMigrateCandidate);
+ if (migrateTarget != null)
+ {
+ _logger.LogWarning(
+ "AgentMigrateServed: tenant={TenantId} serial={Serial} agentVersion={AgentVersion} target={Target}",
+ tenantId, serialNumberHeader, agentVersionHeader, migrateTarget);
+ }
+ else if (rejectedMigrateCandidate != null)
+ {
+ _logger.LogWarning(
+ "AgentMigrateRejected: configured migration target failed validation and is NOT served. tenant={TenantId} candidate={Candidate}",
+ tenantId, rejectedMigrateCandidate);
+ }
+
var response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync(new AgentConfigResponse
{
- ConfigVersion = 32, // V2 OOBE-console / Shift+F10 detection portal toggle (EnableConsoleBypassDetection; tenant-scoped, default ON / opt-out; gates ConsoleBypass host + ConsolePrefetchScanner)
+ ConfigVersion = 33, // Agent endpoint migration on the config channel (MigrateToApiBaseUrl; live-fetch-only, allowlist-validated, one hop, kill wins)
UploadIntervalSeconds = 10,
SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true,
KeepLogFile = tenantConfig.KeepLogFile ?? false,
@@ -262,6 +314,7 @@ namespace AutopilotMonitor.Functions.Functions.Config
DeviceBlocked = killVerdict.IsBlocked,
DeviceKillSignal = killVerdict.IsKill,
UnblockAt = killVerdict.UnblockAt,
+ MigrateToApiBaseUrl = migrateTarget!,
});
return response;
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs
index 5c2e47b7..52e2e13a 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs
@@ -324,6 +324,57 @@ namespace AutopilotMonitor.Shared.Models
}
}
+ // ===== AGENT ENDPOINT MIGRATION (config-channel re-home) =====
+ // The agent's API base URL is compiled into the binary (no custom domain possible in
+ // front of the API — mTLS breaks behind a proxy, Flex custom-domain TLS is broken).
+ // These settings let the OLD backend re-home agents to a NEW base URL via the
+ // agent/config response during a backend or per-tenant region move. Values are
+ // validated against AgentEndpointMigrationRules before being served; invalid values
+ // are logged and ignored (fail-safe: no migration).
+
+ ///
+ /// Global migration target served to ALL agents as
+ /// AgentConfigResponse.MigrateToApiBaseUrl (e.g.
+ /// "https://autopilotmonitor-api-us.azurewebsites.net"). Empty = no global migration.
+ /// Set this on the backend being ABANDONED; clear it once the migration window closes.
+ /// Per-tenant overrides in win over this.
+ ///
+ public string AgentMigrateApiBaseUrl { get; set; } = string.Empty;
+
+ ///
+ /// JSON object mapping tenantId → migration target URL for per-tenant moves (e.g. one
+ /// tenant relocating EU→US). An entry with an EMPTY string value pins that tenant to
+ /// the current backend even while is set (staged
+ /// rollout). Example:
+ /// {"11111111-...": "https://autopilotmonitor-api-us.azurewebsites.net", "22222222-...": ""}
+ ///
+ public string AgentMigrateTenantOverridesJson { get; set; } = string.Empty;
+
+ ///
+ /// Returns the deserialized per-tenant migration overrides (case-insensitive tenant
+ /// keys). Falls back to an empty map for null/empty/invalid JSON so a malformed value
+ /// degrades to "global setting applies" instead of failing config serving.
+ ///
+ public Dictionary GetAgentMigrateTenantOverrides()
+ {
+ var empty = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (string.IsNullOrWhiteSpace(AgentMigrateTenantOverridesJson))
+ return empty;
+ try
+ {
+ var parsed = JsonSerializer.Deserialize>(
+ AgentMigrateTenantOverridesJson,
+ new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ return parsed == null
+ ? empty
+ : new Dictionary(parsed, StringComparer.OrdinalIgnoreCase);
+ }
+ catch
+ {
+ return empty;
+ }
+ }
+
// ===== MODERN DEPLOYMENT NOISE SUPPRESSION =====
///
diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs
index d75eb923..839ca083 100644
--- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs
+++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AgentConfigResponse.cs
@@ -231,6 +231,18 @@ namespace AutopilotMonitor.Shared.Models
/// null = indefinite (or kill).
///
public System.DateTime? UnblockAt { get; set; }
+
+ ///
+ /// Endpoint migration on the control channel: when set, the agent abandons its
+ /// compiled-in API base URL and re-runs backend-client construction + config fetch
+ /// against this URL (backend move without a custom domain in front of the API, or a
+ /// per-tenant region move). Honoured only from a LIVE fetch (the on-disk cache strips
+ /// the field on write and read), validated against
+ /// AgentEndpointMigrationRules on both sides, single hop (the re-fetched
+ /// config cannot chain another migration), and a kill signal always wins over it.
+ /// Old binaries ignore the field. null = no migration.
+ ///
+ public string MigrateToApiBaseUrl { get; set; }
}
///
diff --git a/src/Shared/AutopilotMonitor.Shared/Services/AgentEndpointMigrationRules.cs b/src/Shared/AutopilotMonitor.Shared/Services/AgentEndpointMigrationRules.cs
new file mode 100644
index 00000000..ddf86b91
--- /dev/null
+++ b/src/Shared/AutopilotMonitor.Shared/Services/AgentEndpointMigrationRules.cs
@@ -0,0 +1,100 @@
+using System;
+
+namespace AutopilotMonitor.Shared.Services
+{
+ ///
+ /// Validation rules for the config-channel agent endpoint migration target
+ /// (AgentConfigResponse.MigrateToApiBaseUrl). Single source of truth used by BOTH
+ /// sides — the backend refuses to serve an invalid target, the agent refuses to honour
+ /// one — so a misconfigured admin value or a tampered response can never re-home agents
+ /// to an arbitrary host (defence in depth, same philosophy as the kill-switch hardening).
+ ///
+ public static class AgentEndpointMigrationRules
+ {
+ ///
+ /// Host suffixes a migration target may resolve to. Deliberately narrow: the platform
+ /// only ever runs its API on Azure Functions default hostnames (a custom domain in
+ /// front of the API is impossible — mTLS client-cert auth does not survive a proxy,
+ /// and Flex Consumption custom-domain TLS is broken). Extend here if that ever changes;
+ /// old agent builds keep their compiled-in list, so plan one release of lead time.
+ ///
+ public static readonly string[] AllowedHostSuffixes = { ".azurewebsites.net" };
+
+ /// Defensive cap — a legitimate Functions hostname URL is far shorter.
+ public const int MaxTargetLength = 200;
+
+ ///
+ /// Validates as a migration target and returns the
+ /// normalized base URL (https://{lowercase-host}, no trailing slash) on success.
+ /// Rules: absolute https URI, default port, no userinfo/path/query/fragment, host
+ /// matching .
+ ///
+ public static bool TryNormalizeTarget(string candidate, out string normalized)
+ {
+ normalized = null;
+
+ if (string.IsNullOrWhiteSpace(candidate) || candidate.Length > MaxTargetLength)
+ return false;
+
+ if (!Uri.TryCreate(candidate.Trim(), UriKind.Absolute, out var uri))
+ return false;
+
+ if (!string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ if (!uri.IsDefaultPort)
+ return false;
+
+ if (!string.IsNullOrEmpty(uri.UserInfo))
+ return false;
+
+ // Base URL only — endpoint paths are appended by the agent's clients.
+ if (uri.AbsolutePath != "/" || !string.IsNullOrEmpty(uri.Query) || !string.IsNullOrEmpty(uri.Fragment))
+ return false;
+
+ var host = uri.Host;
+ var allowed = false;
+ foreach (var suffix in AllowedHostSuffixes)
+ {
+ // Suffix match must sit on a label boundary and leave a non-empty prefix:
+ // "evil-azurewebsites.net" and ".azurewebsites.net" itself are both rejected.
+ if (host.Length > suffix.Length
+ && host.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
+ {
+ allowed = true;
+ break;
+ }
+ }
+ if (!allowed)
+ return false;
+
+ normalized = "https://" + host.ToLowerInvariant();
+ return true;
+ }
+
+ ///
+ /// true when is a valid target that actually
+ /// differs from (both compared in normalized form —
+ /// serving the agent its own current URL is a no-op, not a migration).
+ ///
+ public static bool IsEffectiveMigration(string candidate, string currentBaseUrl, out string normalizedTarget)
+ {
+ normalizedTarget = null;
+
+ if (!TryNormalizeTarget(candidate, out var target))
+ return false;
+
+ // The current base URL may not itself pass the allowlist (dev --api-url override);
+ // fall back to a plain trim-and-lower comparison in that case.
+ var currentNormalized = TryNormalizeTarget(currentBaseUrl, out var normCurrent)
+ ? normCurrent
+ : (currentBaseUrl ?? string.Empty).Trim().TrimEnd('/').ToLowerInvariant();
+
+ if (string.Equals(target, currentNormalized, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ normalizedTarget = target;
+ return true;
+ }
+ }
+}
diff --git a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx
index c1fb8098..9d863e5b 100644
--- a/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/AdminConfigContext.tsx
@@ -26,6 +26,10 @@ interface AdminConfigContextValue {
setGlobalAdminRateLimit: (value: number) => void;
platformStatsBlobSasUrl: string;
setPlatformStatsBlobSasUrl: (value: string) => void;
+ agentMigrateApiBaseUrl: string;
+ setAgentMigrateApiBaseUrl: (value: string) => void;
+ agentMigrateTenantOverridesJson: string;
+ setAgentMigrateTenantOverridesJson: (value: string) => void;
collectorIdleTimeoutMinutes: number;
setCollectorIdleTimeoutMinutes: (value: number) => void;
desktopDetectorNoCandidateTimeoutMinutes: number;
@@ -130,6 +134,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const [userRateLimit, setUserRateLimit] = useState(120);
const [globalAdminRateLimit, setGlobalAdminRateLimit] = useState(600);
const [platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl] = useState("");
+ const [agentMigrateApiBaseUrl, setAgentMigrateApiBaseUrl] = useState("");
+ const [agentMigrateTenantOverridesJson, setAgentMigrateTenantOverridesJson] = useState("");
const [collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes] = useState(15);
const [desktopDetectorNoCandidateTimeoutMinutes, setDesktopDetectorNoCandidateTimeoutMinutes] = useState(10);
const [maxSessionWindowHours, setMaxSessionWindowHours] = useState(24);
@@ -191,6 +197,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setUserRateLimit(data.userRateLimitRequestsPerMinute ?? 120);
setGlobalAdminRateLimit(data.globalAdminRateLimitRequestsPerMinute ?? 600);
setPlatformStatsBlobSasUrl(data.platformStatsBlobSasUrl ?? "");
+ setAgentMigrateApiBaseUrl(data.agentMigrateApiBaseUrl ?? "");
+ setAgentMigrateTenantOverridesJson(data.agentMigrateTenantOverridesJson ?? "");
setCollectorIdleTimeoutMinutes(data.collectorIdleTimeoutMinutes ?? 15);
setDesktopDetectorNoCandidateTimeoutMinutes(data.desktopDetectorNoCandidateTimeoutMinutes ?? 10);
setMaxSessionWindowHours(data.maxSessionWindowHours ?? 24);
@@ -296,6 +304,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
userRateLimitRequestsPerMinute: userRateLimit,
globalAdminRateLimitRequestsPerMinute: globalAdminRateLimit,
platformStatsBlobSasUrl: platformStatsBlobSasUrl.trim(),
+ agentMigrateApiBaseUrl: agentMigrateApiBaseUrl.trim(),
+ agentMigrateTenantOverridesJson: agentMigrateTenantOverridesJson.trim(),
collectorIdleTimeoutMinutes,
desktopDetectorNoCandidateTimeoutMinutes,
maxSessionWindowHours,
@@ -332,7 +342,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
} finally {
setSavingConfig(false);
}
- }, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, getAccessToken]);
+ }, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, maxSessionWindowHours, maintenanceBlockDurationHours, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, getAccessToken]);
// Reset admin config
const handleResetAdminConfig = useCallback(() => {
@@ -341,6 +351,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setUserRateLimit(adminConfig.userRateLimitRequestsPerMinute ?? 120);
setGlobalAdminRateLimit(adminConfig.globalAdminRateLimitRequestsPerMinute ?? 600);
setPlatformStatsBlobSasUrl(adminConfig.platformStatsBlobSasUrl ?? "");
+ setAgentMigrateApiBaseUrl(adminConfig.agentMigrateApiBaseUrl ?? "");
+ setAgentMigrateTenantOverridesJson(adminConfig.agentMigrateTenantOverridesJson ?? "");
setCollectorIdleTimeoutMinutes(adminConfig.collectorIdleTimeoutMinutes ?? 15);
setDesktopDetectorNoCandidateTimeoutMinutes(adminConfig.desktopDetectorNoCandidateTimeoutMinutes ?? 10);
setMaxSessionWindowHours(adminConfig.maxSessionWindowHours ?? 24);
@@ -485,6 +497,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
userRateLimit, setUserRateLimit,
globalAdminRateLimit, setGlobalAdminRateLimit,
platformStatsBlobSasUrl, setPlatformStatsBlobSasUrl,
+ agentMigrateApiBaseUrl, setAgentMigrateApiBaseUrl,
+ agentMigrateTenantOverridesJson, setAgentMigrateTenantOverridesJson,
collectorIdleTimeoutMinutes, setCollectorIdleTimeoutMinutes,
desktopDetectorNoCandidateTimeoutMinutes, setDesktopDetectorNoCandidateTimeoutMinutes,
maxSessionWindowHours, setMaxSessionWindowHours,
diff --git a/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx
index 26a915de..3d1ec95e 100644
--- a/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx
+++ b/src/Web/autopilot-monitor-web/app/admin/components/AdminConfigSettingsSection.tsx
@@ -14,6 +14,10 @@ interface AdminConfigSettingsSectionProps {
setGlobalAdminRateLimit: (value: number) => void;
platformStatsBlobSasUrl: string;
setPlatformStatsBlobSasUrl: (value: string) => void;
+ agentMigrateApiBaseUrl: string;
+ setAgentMigrateApiBaseUrl: (value: string) => void;
+ agentMigrateTenantOverridesJson: string;
+ setAgentMigrateTenantOverridesJson: (value: string) => void;
collectorIdleTimeoutMinutes: number;
setCollectorIdleTimeoutMinutes: (value: number) => void;
desktopDetectorNoCandidateTimeoutMinutes: number;
@@ -44,6 +48,10 @@ export function AdminConfigSettingsSection({
setGlobalAdminRateLimit,
platformStatsBlobSasUrl,
setPlatformStatsBlobSasUrl,
+ agentMigrateApiBaseUrl,
+ setAgentMigrateApiBaseUrl,
+ agentMigrateTenantOverridesJson,
+ setAgentMigrateTenantOverridesJson,
collectorIdleTimeoutMinutes,
setCollectorIdleTimeoutMinutes,
desktopDetectorNoCandidateTimeoutMinutes,
@@ -155,6 +163,43 @@ export function AdminConfigSettingsSection({
+