diff --git a/skills/appdomain-migration/SKILL.md b/skills/appdomain-migration/SKILL.md new file mode 100644 index 0000000000..4729322d3f --- /dev/null +++ b/skills/appdomain-migration/SKILL.md @@ -0,0 +1,122 @@ +--- +name: appdomain-migration +description: Guides migration of .NET Framework AppDomain usage to modern .NET alternatives. Use when modernizing code that relies on AppDomain for plugin isolation, dynamic assembly loading, sandboxing, or configuration isolation. +--- + +# AppDomain Migration + +This skill helps an agent migrate .NET Framework code that uses `System.AppDomain` to the appropriate modern .NET (6+/8+) replacement. Because there is no single direct replacement for AppDomains, the skill identifies the usage pattern first, then applies the correct migration strategy. + +## When to Use + +- Migrating a .NET Framework project to .NET 6+ that uses `AppDomain.CreateDomain` +- Replacing `AppDomain`-based plugin or add-in hosting with `AssemblyLoadContext` +- Removing `MarshalByRefObject` cross-domain communication patterns +- Converting dynamic assembly loading that depends on AppDomain isolation +- Replacing AppDomain-based sandboxing with process-level isolation +- Resolving build errors related to removed AppDomain APIs after a target framework change + +## When Not to Use + +- The code only uses `AppDomain.CurrentDomain` for event subscriptions like `UnhandledException`, `AssemblyResolve`, or `ProcessExit`. These events still work in modern .NET and do not require migration. Do not refactor them to use `AssemblyLoadContext` or other replacements. +- The project will remain on .NET Framework indefinitely +- The AppDomain usage is inside a third-party library you do not control + +## Inputs + +| Input | Required | Description | +|-------|----------|-------------| +| Source project or solution | Yes | The .NET Framework project containing AppDomain usage | +| Target framework | Yes | The modern .NET version to target (e.g., `net8.0`) | +| AppDomain usage locations | Recommended | Files or classes that reference `AppDomain.CreateDomain`, `MarshalByRefObject`, or cross-domain delegates | + +## Workflow + +### Step 1: Inventory AppDomain usage + +Search the codebase for all AppDomain-related APIs: + +- `AppDomain.CreateDomain` +- `AppDomain.Unload` +- `AppDomain.CurrentDomain.Load` +- `MarshalByRefObject` subclasses +- `CrossAppDomainDelegate` +- `AppDomainSetup` +- `[Serializable]` types used for cross-domain data transfer +- `DoCallBack` invocations +- `SetData` / `GetData` for cross-domain state + +Record each usage location, the pattern it represents, and any cross-domain types involved. + +### Step 2: Classify each usage pattern + +Categorize every usage into one of the following patterns: + +| Pattern | Description | Modern replacement | +|---------|-------------|--------------------| +| **Plugin isolation** | Loading and unloading third-party assemblies in an isolated domain | `AssemblyLoadContext` with `isCollectible: true` | +| **Dynamic assembly loading** | Loading assemblies by path or name at runtime without isolation requirements | `AssemblyLoadContext.Default.LoadFromAssemblyPath` or `Assembly.LoadFrom` | +| **Sandboxing / partial trust** | Restricting permissions for untrusted code | Separate process with restricted OS-level permissions | +| **Configuration isolation** | Using per-domain config files via `AppDomainSetup.ConfigurationFile` | `IConfiguration` with per-component config sources | +| **Unloadability** | Loading code that must be unloaded to free memory or update in place | Collectible `AssemblyLoadContext` | +| **Cross-domain remoting** | Using `MarshalByRefObject` proxies to call across domains | In-process interfaces across `AssemblyLoadContext` boundaries, or out-of-process communication (named pipes, gRPC) | + +**Critical:** If a single AppDomain serves multiple purposes, list every pattern separately and address each one. Do not pick a single replacement strategy for a multi-pattern AppDomain. + +### Step 3: Apply the replacement for each pattern + +Use the modern replacement from the table above. Key implementation notes per pattern: + +- **Plugin isolation / unloadability**: Create a custom `AssemblyLoadContext` subclass with `isCollectible: true`. Use `AssemblyDependencyResolver` in the `Load` override. Override `LoadUnmanagedDll` to resolve native dependencies from the plugin directory. Do not use `Assembly.LoadFrom` — it loads into the default context and bypasses isolation. Always use `AssemblyLoadContext.LoadFromAssemblyPath` on the custom context. After calling `Unload()`, release all references to types from that context and use `WeakReference` to verify the context is garbage collected. +- **MarshalByRefObject / cross-domain remoting**: Define a shared interface in an assembly loaded by the default context. The plugin implements that interface and the host casts to it. If strong security isolation is needed, use a separate process with IPC instead. +- **Sandboxing / partial trust**: Modern .NET does not support CAS or partial trust. Remove all `PermissionSet`, `SecurityPermission`, and `AppDomain.SetAppDomainPolicy` calls. Replace with a separate process running under restricted OS permissions (Windows: Job Objects or restricted user account; Linux: containers, seccomp, or AppArmor). +- **Configuration isolation**: Replace `AppDomainSetup.ConfigurationFile` with `Microsoft.Extensions.Configuration`. Create a per-component `IConfiguration` instance using `ConfigurationBuilder`. Remove `AppDomainSetup` and any `ConfigurationManager` calls that relied on per-domain config files. +- **Cross-domain state (`SetData` / `GetData`)**: Replace with explicit parameter passing, dependency injection, or `AsyncLocal` for ambient state. Do not rely on implicit shared state. +- **`DoCallBack`**: Replace with a direct method call on the loaded type. Since `AssemblyLoadContext` does not create a remoting boundary, there is no need for a callback delegate — load the assembly, instantiate the type, and invoke methods directly. + +### Step 4: Clean up removed APIs + +After migrating all patterns, remove or replace any remaining references: + +| Removed API | Replacement | +|-------------|-------------| +| `AppDomain.CreateDomain` | `new PluginLoadContext(path)` | +| `AppDomain.Unload` | `AssemblyLoadContext.Unload()` | +| `AppDomain.CurrentDomain.Load(byte[])` | `AssemblyLoadContext.Default.LoadFromStream(new MemoryStream(bytes))` | +| `AppDomain.SetData` / `GetData` | Static state, dependency injection, or `AsyncLocal` | +| `AppDomain.DoCallBack` | Direct method call or IPC | +| `MarshalByRefObject` | Shared interface or IPC | +| `[Serializable]` for cross-domain transfer | Shared types in a common assembly, or DTO serialization over IPC | + +### Step 5: Verify the migration + +1. Build the project targeting the new framework. Confirm zero `AppDomain`-related compile errors. +2. Run existing tests. If tests created AppDomains for isolation, update them to use a custom `AssemblyLoadContext` with `isCollectible: true`. +3. For unloadability scenarios, add a test that: + - Loads an assembly into a collectible `AssemblyLoadContext` + - Unloads the context + - Uses a `WeakReference` to confirm the context was garbage collected +4. For plugin scenarios, verify that plugins load, execute, and unload without memory leaks. +5. Search the codebase for any remaining references to `AppDomain.CreateDomain`, `MarshalByRefObject`, or `CrossAppDomainDelegate`. + +## Validation + +- [ ] No references to `AppDomain.CreateDomain` remain in the migrated code +- [ ] No `MarshalByRefObject` subclasses remain (unless still targeting .NET Framework in a multi-target build) +- [ ] Project builds cleanly against the target framework with no AppDomain-related errors +- [ ] Plugin load/unload scenarios work correctly with `AssemblyLoadContext` +- [ ] If collectible contexts are used, a `WeakReference` test confirms unloading works +- [ ] Cross-domain communication replaced with shared interfaces or IPC +- [ ] No CAS or partial trust APIs remain (`PermissionSet`, `SecurityPermission`, etc.) +- [ ] Existing tests pass or have been updated for the new isolation model + +## Common Pitfalls + +| Pitfall | Solution | +|---------|----------| +| Holding references to types from an unloaded context prevents GC | Ensure all references (including event handlers) are released before calling `Unload()`. Use `WeakReference` to verify. | +| Shared types loaded in both default and plugin contexts cause `InvalidCastException` | Load shared interface assemblies only in the default context. Use `AssemblyDependencyResolver` and override `Load` to return `null` for shared assemblies so they fall through to the default context. | +| Assuming `AssemblyLoadContext` provides security isolation | It does not. `AssemblyLoadContext` provides assembly isolation, not permission isolation. Use process boundaries for security. | +| Removing `[Serializable]` from types still used by other serializers | Only remove `[Serializable]` if it was solely for cross-AppDomain marshaling. Check for `BinaryFormatter`, remoting, or other serialization usage first. | +| Using `Assembly.LoadFrom` instead of `AssemblyLoadContext.LoadFromAssemblyPath` | `LoadFrom` loads into the default context and does not provide isolation. Always use a custom `AssemblyLoadContext` when isolation is needed. | +| Forgetting to handle unmanaged (native) DLL loading in the plugin context | Override `LoadUnmanagedDll` in your custom `AssemblyLoadContext` to resolve native dependencies from the plugin directory. | diff --git a/skills/appdomain-migration/tests/eval.yaml b/skills/appdomain-migration/tests/eval.yaml new file mode 100644 index 0000000000..092c37f1ed --- /dev/null +++ b/skills/appdomain-migration/tests/eval.yaml @@ -0,0 +1,186 @@ +scenarios: + - name: "Plugin host with AppDomain isolation" + prompt: | + Migrate this .NET Framework code to .NET 8. Provide complete migration guidance with code and pitfalls. + + ```csharp + public class PluginHost + { + private AppDomain _pluginDomain; + public IPluginResult LoadAndExecute(string pluginDllPath, string inputData) + { + var setup = new AppDomainSetup + { + ApplicationBase = Path.GetDirectoryName(pluginDllPath), + ConfigurationFile = Path.Combine(Path.GetDirectoryName(pluginDllPath), "plugin.config") + }; + _pluginDomain = AppDomain.CreateDomain("PluginDomain", null, setup); + var proxy = (PluginProxy)_pluginDomain.CreateInstanceAndUnwrap( + typeof(PluginProxy).Assembly.FullName, typeof(PluginProxy).FullName); + var result = proxy.Execute(pluginDllPath, inputData); + AppDomain.Unload(_pluginDomain); + return result; + } + } + + [Serializable] + public class PluginResult : IPluginResult + { + public string Output { get; set; } + public bool Success { get; set; } + } + + public class PluginProxy : MarshalByRefObject + { + public PluginResult Execute(string dllPath, string input) + { + var assembly = Assembly.LoadFrom(dllPath); + var type = assembly.GetTypes().First(t => typeof(IPlugin).IsAssignableFrom(t)); + var plugin = (IPlugin)Activator.CreateInstance(type); + return plugin.Run(input); + } + } + ``` + assertions: + - type: output_contains + value: "AssemblyLoadContext" + - type: output_contains + value: "isCollectible" + - type: output_contains + value: "LoadUnmanagedDll" + - type: exit_success + rubric: + - "Warns about InvalidCastException from shared types loaded in both default and plugin AssemblyLoadContext" + - "Mentions WeakReference to verify unloading works" + - "Cautions about removing [Serializable] only after checking for BinaryFormatter or other serializer usage" + - "Recommends using AssemblyLoadContext.LoadFromAssemblyPath instead of Assembly.LoadFrom for isolation" + - "Addresses the ConfigurationFile replacement with IConfiguration" + timeout: 120 + + - name: "Sandboxing with CAS and partial trust" + prompt: | + Migrate this .NET Framework sandboxing code to .NET 8. Provide complete migration guidance with code and pitfalls. + + ```csharp + public class SandboxRunner + { + public object RunUntrusted(string assemblyPath, string typeName, string methodName) + { + var permissions = new PermissionSet(PermissionState.None); + permissions.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution)); + permissions.AddPermission(new FileIOPermission(FileIOPermissionAccess.Read, @"C:\SafeData")); + var setup = new AppDomainSetup { ApplicationBase = Path.GetDirectoryName(assemblyPath) }; + var domain = AppDomain.CreateDomain("Sandbox", null, setup, permissions); + domain.SetData("SharedConfig", new Dictionary { ["MaxItems"] = "100" }); + try + { + var instance = domain.CreateInstanceAndUnwrap( + Path.GetFileNameWithoutExtension(assemblyPath), typeName); + var result = instance.GetType().GetMethod(methodName).Invoke(instance, null); + return result; + } + finally { AppDomain.Unload(domain); } + } + } + ``` + assertions: + - type: output_contains + value: "process" + - type: output_not_contains + value: "AssemblyLoadContext provides security" + - type: exit_success + rubric: + - "Correctly identifies this as a sandboxing pattern, not a plugin isolation pattern" + - "States that CAS and partial trust are not supported in modern .NET" + - "Recommends separate process isolation rather than AssemblyLoadContext for security" + - "Addresses the SetData/GetData replacement with dependency injection, arguments, or AsyncLocal" + - "Does not suggest AssemblyLoadContext as a security boundary" + timeout: 120 + + - name: "Multi-pattern AppDomain decomposition" + prompt: | + Migrate this .NET Framework code to .NET 8. This code uses AppDomain for MULTIPLE purposes simultaneously. Provide complete migration guidance. + + ```csharp + public class WorkerManager + { + public void RunWorker(string workerDllPath, Dictionary config) + { + var setup = new AppDomainSetup + { + ApplicationBase = Path.GetDirectoryName(workerDllPath), + ConfigurationFile = Path.Combine(Path.GetDirectoryName(workerDllPath), "worker.exe.config") + }; + var domain = AppDomain.CreateDomain("Worker_" + Guid.NewGuid(), null, setup); + foreach (var kvp in config) + domain.SetData(kvp.Key, kvp.Value); + domain.DoCallBack(() => + { + var workerConfig = AppDomain.CurrentDomain.GetData("ConnectionString") as string; + var maxRetries = AppDomain.CurrentDomain.GetData("MaxRetries") as string; + var assembly = Assembly.LoadFrom(workerDllPath); + var workerType = assembly.GetTypes().First(t => t.Name == "Worker"); + var worker = Activator.CreateInstance(workerType); + workerType.GetMethod("Start").Invoke(worker, new object[] { workerConfig, int.Parse(maxRetries) }); + }); + AppDomain.Unload(domain); + } + } + ``` + assertions: + - type: output_contains + value: "AssemblyLoadContext" + - type: output_contains + value: "IConfiguration" + - type: exit_success + rubric: + - "Explicitly decomposes the code into multiple patterns before migrating (plugin loading, config isolation, cross-domain state, DoCallBack)" + - "Addresses DoCallBack replacement with direct method invocation" + - "Addresses SetData/GetData replacement with dependency injection or parameters" + - "Addresses ConfigurationFile replacement with IConfiguration or ConfigurationBuilder" + - "Warns about Assembly.LoadFrom loading into the default context without isolation" + timeout: 120 + + - name: "CurrentDomain event subscriptions only - should not migrate" + prompt: | + Migrate this .NET Framework code to .NET 8. Provide your migration guidance. + + ```csharp + public class AppStartup + { + public static void Initialize() + { + AppDomain.CurrentDomain.UnhandledException += (sender, args) => + { + var ex = (Exception)args.ExceptionObject; + Logger.Fatal($"Unhandled exception: {ex}"); + Environment.Exit(1); + }; + AppDomain.CurrentDomain.AssemblyResolve += (sender, args) => + { + var name = new AssemblyName(args.Name); + var probePath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "libs", name.Name + ".dll"); + if (File.Exists(probePath)) + return Assembly.LoadFrom(probePath); + return null; + }; + AppDomain.CurrentDomain.ProcessExit += (sender, args) => + { + Logger.Info("Application shutting down"); + CleanupResources(); + }; + } + } + ``` + assertions: + - type: output_contains + value: "no migration" + - type: output_not_contains + value: "AssemblyLoadContext" + - type: exit_success + rubric: + - "Correctly identifies that this code only uses AppDomain.CurrentDomain event subscriptions" + - "States that these events still work in modern .NET and do not require migration" + - "Does NOT recommend replacing AssemblyResolve with AssemblyLoadContext" + - "Does NOT over-migrate working code" + timeout: 120