26 changed files with 626 additions and 5 deletions
+1 -1
View File
@@ -1 +1 @@
<Application x:Class="Launcher.Next.App.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" StartupUri="MainWindow.xaml" /> <Application x:Class="Launcher.Next.App.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" />
+6 -1
View File
@@ -1 +1,6 @@
namespace Launcher.Next.App; public partial class App : System.Windows.Application { } namespace Launcher.Next.App;
public partial class App : System.Windows.Application
{
protected override async void OnStartup(System.Windows.StartupEventArgs e)
{ base.OnStartup(e); if (e.Args.FirstOrDefault()?.Equals("pilot", StringComparison.OrdinalIgnoreCase) == true) { Shutdown(await PilotCli.RunAsync(e.Args.Skip(1).ToArray())); return; } new MainWindow().Show(); }
}
+1 -1
View File
@@ -1 +1 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>WinExe</OutputType><TargetFramework>net8.0-windows</TargetFramework><UseWPF>true</UseWPF><EnableWindowsTargeting>true</EnableWindowsTargeting></PropertyGroup><ItemGroup><ProjectReference Include="..\Launcher.Next.Application\Launcher.Next.Application.csproj" /><ProjectReference Include="..\Launcher.Next.Observability\Launcher.Next.Observability.csproj" /></ItemGroup></Project> <Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>WinExe</OutputType><TargetFramework>net8.0-windows</TargetFramework><UseWPF>true</UseWPF><EnableWindowsTargeting>true</EnableWindowsTargeting></PropertyGroup><ItemGroup><ProjectReference Include="..\Launcher.Next.Application\Launcher.Next.Application.csproj" /><ProjectReference Include="..\Launcher.Next.Observability\Launcher.Next.Observability.csproj" /><ProjectReference Include="..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" /><ProjectReference Include="..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" /></ItemGroup></Project>
+167
View File
@@ -0,0 +1,167 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Launcher.Next.Autodesk;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
namespace Launcher.Next.App;
internal static class PilotCli
{
private const int Success = 0, Blocked = 2, DiscoveryFailed = 3, AcadRunning = 4, SessionConflict = 5, ProcessFailed = 6, RollbackAfterExitFailed = 7, ExplicitRollbackFailed = 8, Unexpected = 10;
public static async Task<int> RunAsync(string[] args)
{
try
{
if (args.Length == 0 || args[0] is not ("prepare" or "run" or "status" or "rollback")) return Blocked;
var command = args[0];
if (!TryParse(command, args.Skip(1).ToArray(), out var options, out var error)) return Fail(Blocked, error!);
var root = options.PilotRoot!;
if (command == "status") return await StatusAsync(root);
if (command == "rollback") return await RollbackAsync(root);
if (!TryReadConfiguration(root, out var config, out error)) return Fail(Blocked, error!);
if (!string.Equals(config.PilotRoot, root, StringComparison.OrdinalIgnoreCase)) return Fail(Blocked, "PILOT_ROOT_CONFIGURATION_MISMATCH");
if (!TryBuildManifest(options.PackageSource!, options.EntryAssembly!, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!);
Directory.CreateDirectory(Path.Combine(root, "catalog"));
await File.WriteAllTextAsync(Path.Combine(root, "catalog", "P_Rec_Annot.manifest.json"), JsonSerializer.Serialize(manifest));
if (!File.Exists(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe")) return Fail(DiscoveryFailed, "PLANT3D_2026_NOT_FOUND");
if (command == "run" && File.Exists(SessionPath(root)) && await HasActiveSession(root)) return await HandleExistingSession(root);
var service = CreateService();
var product = new ProductInstallation(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe", "2026", "x64", []);
var request = new PilotPreparationRequest(config, product, manifest!, manifestHash!, options.PackageSource!, root, Path.Combine(root, "startup", "prepare-only.scr"));
var prepared = await service.PrepareAsync(request, CancellationToken.None);
if (prepared.Plan is null) return Fail(prepared.SafetyDecision.Violations.Contains("ACAD_ALREADY_RUNNING") ? AcadRunning : Blocked, string.Join(',', prepared.SafetyDecision.Violations));
PrintPlan(prepared.Plan);
if (command == "prepare") return Success;
var sessionScript = Path.Combine(root, "startup", Guid.NewGuid().ToString("N") + ".scr");
request = request with { StartupScriptPath = sessionScript };
prepared = await service.PrepareAsync(request, CancellationToken.None);
if (prepared.Plan is null) return Fail(prepared.SafetyDecision.Violations.Contains("ACAD_ALREADY_RUNNING") ? AcadRunning : Blocked, string.Join(',', prepared.SafetyDecision.Violations));
var permits = PermitService;
var permit = permits.Issue(prepared.Plan, config.ExecutionPermitLifetimeSeconds);
var execution = await service.ExecuteAsync(new(request, prepared.Plan, permit), CancellationToken.None);
if (!execution.Started) return Fail(execution.BlockCode == "ACAD_ALREADY_RUNNING" ? AcadRunning : ProcessFailed, execution.BlockCode ?? "PROCESS_START_FAILED");
Console.WriteLine($"Plant 3D запущен. PID: {execution.ProcessId}. P_Rec_Annot загружается через startup script.");
if (!options.WaitForExit) return Success;
try
{
await WaitForExitAsync(execution.ProcessId!.Value);
Console.WriteLine("Plant 3D завершён штатно.");
}
catch (Exception ex)
{
Console.Error.WriteLine($"PROCESS_WAIT_FAILED: {ex.Message}");
return ProcessFailed;
}
finally
{
if (options.RollbackAfterExit)
{
var rollback = await service.RollbackAsync(new(root, manifest!.PackageId, manifest.Version, execution.StartupScriptPath!, null, execution.SessionId, execution.ActivationSnapshotPath), CancellationToken.None);
if (!rollback.Completed) Console.Error.WriteLine(rollback.ErrorCode ?? "ROLLBACK_FAILED");
}
}
return await IsRolledBack(root) ? Success : RollbackAfterExitFailed;
}
catch (Exception ex) { return Fail(Unexpected, ex.Message); }
}
private static readonly IExecutionPermitService PermitService = new ExecutionPermitService();
private static PilotExecutionService CreateService() => new(new PackageManifestValidator(), new PilotRootPolicy(@"C:\AutoCAD User Support"), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker());
private static async Task WaitForExitAsync(int processId) { using var process = Process.GetProcessById(processId); await process.WaitForExitAsync(); }
private static async Task<int> RollbackAsync(string root)
{
var result = await CreateService().RollbackAsync(new(root, "P_Rec_Annot", null, string.Empty, null), CancellationToken.None);
return result.Completed ? Success : Fail(ExplicitRollbackFailed, result.ErrorCode ?? "ROLLBACK_FAILED");
}
private static async Task<int> StatusAsync(string root)
{
var session = SessionPath(root);
if (!File.Exists(session)) { Console.WriteLine("State: Clean"); return Success; }
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(session));
var state = document.RootElement.GetProperty("State").GetString() ?? "Unknown";
Console.WriteLine($"State: {state}");
return state is "RolledBack" ? Success : SessionConflict;
}
private static async Task<int> HandleExistingSession(string root)
{
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(SessionPath(root)));
var state = document.RootElement.GetProperty("State").GetString();
return state == "RolledBack" ? Success : Fail(SessionConflict, "PILOT_SESSION_ALREADY_ACTIVE");
}
private static async Task<bool> HasActiveSession(string root)
{
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(SessionPath(root)));
return document.RootElement.GetProperty("State").GetString() != "RolledBack";
}
private static async Task<bool> IsRolledBack(string root)
{
if (!File.Exists(SessionPath(root))) return false;
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(SessionPath(root)));
return document.RootElement.GetProperty("State").GetString() == "RolledBack";
}
private static string SessionPath(string root) => Path.Combine(root, "state", "pilot-session.json");
private static bool TryReadConfiguration(string root, out PilotLocalConfiguration config, out string? error)
{
config = PilotLocalConfiguration.SafeDefault; error = null; var path = Path.Combine(root, "config", "pilot.local.json");
if (!File.Exists(path)) { error = "PILOT_CONFIGURATION_MISSING"; return false; }
var parsed = PilotConfigurationParser.Parse(File.ReadAllText(path)); config = parsed.Configuration;
if (!parsed.IsValid) { error = "PILOT_CONFIGURATION_INVALID"; return false; }
return true;
}
private static bool TryBuildManifest(string source, string entry, out PackageManifest? manifest, out string? hash, out string? error)
{
manifest = null; hash = null; error = null;
if (!Directory.Exists(source) || !File.Exists(Path.Combine(source, entry))) { error = "PACKAGE_SOURCE_INVALID"; return false; }
var list = new List<PackageFile>();
foreach (var path in Directory.GetFiles(source, "*", SearchOption.AllDirectories))
{
var relative = Path.GetRelativePath(source, path);
var name = Path.GetFileName(relative);
if (relative.StartsWith("..") || IsForbidden(name)) { error = "PACKAGE_FILE_FORBIDDEN"; return false; }
using var stream = File.OpenRead(path); var bytes = SHA256.HashData(stream);
list.Add(new(relative, new FileInfo(path).Length, Convert.ToHexString(bytes)));
}
var version = "0.1.0-pilot-ea3a9e1";
try { version = System.Reflection.AssemblyName.GetAssemblyName(Path.Combine(source, entry)).Version?.ToString() ?? version; } catch { }
manifest = new PackageManifest(1, "P_Rec_Annot", version, "Plant3D", 2026, "x64", entry, list.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase).ToArray(), new("netload", "PHP_REC_ANNOT"), "0.1.0", ["R25.1"], "net8.0-windows", null, null, "pilot", false, true);
var json = JsonSerializer.Serialize(manifest); hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(json)));
return true;
}
private static bool IsForbidden(string name) => name.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase) || name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) || name.Contains("test", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Ac", StringComparison.OrdinalIgnoreCase) || name.StartsWith("PnP", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Autodesk.ProcessPower", StringComparison.OrdinalIgnoreCase);
private static void PrintPlan(PilotLaunchPlan plan) => Console.WriteLine($"Plan={plan.PlanId}; Package={plan.PackageId}/{plan.PackageVersion}; NETLOAD=1; Args={string.Join(' ', plan.Process.Arguments)}; /p=false");
private static int Fail(int code, string error) { Console.Error.WriteLine(error); return code; }
private sealed record Options(string? PackageSource, string? EntryAssembly, string? PilotRoot, bool WaitForExit, bool RollbackAfterExit);
private static bool TryParse(string command, string[] args, out Options options, out string? error)
{
options = new(null, null, null, false, false); error = null; var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var wait = false; var rollback = false;
for (var i = 0; i < args.Length; i++)
{
var token = args[i];
if (token is "--wait-for-exit" or "--rollback-after-exit")
{
if (command != "run" || (token == "--wait-for-exit" ? wait : rollback)) { error = "PILOT_ARGUMENT_INVALID"; return false; }
if (token == "--wait-for-exit") wait = true; else rollback = true; continue;
}
if (token is not ("--package-source" or "--entry-assembly" or "--pilot-root") || i + 1 >= args.Length || args[i + 1].StartsWith("--") || !values.TryAdd(token, args[++i])) { error = "PILOT_ARGUMENT_INVALID"; return false; }
}
if (rollback && !wait) { error = "ROLLBACK_AFTER_EXIT_REQUIRES_WAIT"; return false; }
if (!values.TryGetValue("--pilot-root", out var root) || !Path.IsPathFullyQualified(root) || root.StartsWith("\\\\")) { error = "PILOT_ROOT_INVALID"; return false; }
if (command is "prepare" or "run")
{
if (!values.TryGetValue("--package-source", out var source) || source is null || !Path.IsPathFullyQualified(source) || source.StartsWith("\\\\") || Path.GetFullPath(source).StartsWith(Path.GetFullPath(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) { error = "PACKAGE_SOURCE_INVALID"; return false; }
if (!values.TryGetValue("--entry-assembly", out var entry) || entry is null || !string.Equals(Path.GetFileName(entry), entry, StringComparison.Ordinal) || entry.Contains("..", StringComparison.Ordinal)) { error = "ENTRY_ASSEMBLY_INVALID"; return false; }
}
else if (values.Keys.Any(x => x != "--pilot-root")) { error = "PILOT_ARGUMENT_INVALID"; return false; }
options = new(values.GetValueOrDefault("--package-source"), values.GetValueOrDefault("--entry-assembly"), root, wait, rollback); return true;
}
private sealed class Probe : IAutoCadProcessProbe { public bool IsAcadRunning() => Process.GetProcessesByName("acad").Any(); }
}
+1 -1
View File
@@ -15,7 +15,7 @@ public sealed class PlantProductDiscovery(IRegistryReader registry) : IProductDi
public sealed class StartupScriptBuilder : IStartupScriptBuilder public sealed class StartupScriptBuilder : IStartupScriptBuilder
{ {
public string BuildNetloadScript(string root, string entry) public string BuildNetloadScript(string root, string entry)
{ var full = Path.GetFullPath(Path.Combine(root, entry)); if (!full.StartsWith(Path.GetFullPath(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(full)) throw new InvalidOperationException("UNTRUSTED_ENTRY_PATH"); return $"(command \"._netload\" \"{full.Replace("\\", "\\\\").Replace("\"", "\\\"")}\")\n"; } { var full = Path.GetFullPath(Path.Combine(root, entry)); if (!full.StartsWith(Path.GetFullPath(root) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || !File.Exists(full)) throw new InvalidOperationException("UNTRUSTED_ENTRY_PATH"); return $"_.NETLOAD\n\"{full.Replace("\"", "\\\"")}\"\n"; }
} }
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
{ {
+2 -1
View File
@@ -1,6 +1,7 @@
namespace Launcher.Next.Core; namespace Launcher.Next.Core;
public enum LauncherMode { Legacy, NextPilot, NextPreferred } public enum LauncherMode { Legacy, DryRun, NextPilot, NextPreferred }
public enum PluginExecutionMode { Smoke, DryRun, Apply }
public sealed record ProductInstallation(string ExecutablePath, string ProductVersion, string Architecture, IReadOnlyList<string> Profiles); public sealed record ProductInstallation(string ExecutablePath, string ProductVersion, string Architecture, IReadOnlyList<string> Profiles);
public sealed record PackageFile(string Path, long Length, string Sha256, bool Optional = false); public sealed record PackageFile(string Path, long Length, string Sha256, bool Optional = false);
public sealed record StartupDefinition(string Type, string Command); public sealed record StartupDefinition(string Type, string Command);
+76
View File
@@ -0,0 +1,76 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Launcher.Next.Core;
public sealed record PilotLocalConfiguration
{
public int SchemaVersion { get; init; }
public LauncherMode LauncherMode { get; init; } = LauncherMode.DryRun;
public bool PilotEnabled { get; init; }
public bool AllowProcessStart { get; init; }
public bool RequireExplicitExecute { get; init; } = true;
public PluginExecutionMode PluginExecutionMode { get; init; } = PluginExecutionMode.Smoke;
// Apply is never a normal pilot default. It is accepted only by this
// dedicated test branch and still requires the plugin-side fixture permit.
public bool AllowApply { get; init; }
public string? ApplyPermitPath { get; init; }
public string? PilotRoot { get; init; }
public string? SelectedProductId { get; init; }
public string? SelectedProfileName { get; init; }
public int ExecutionPermitLifetimeSeconds { get; init; } = 300;
public static PilotLocalConfiguration SafeDefault { get; } = new();
}
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
{ public bool IsValid => Errors.Count == 0; }
public static class PilotConfigurationParser
{
public static PilotConfigurationLoadResult Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new(PilotLocalConfiguration.SafeDefault, []);
try { var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }; options.Converters.Add(new JsonStringEnumConverter()); var config = JsonSerializer.Deserialize<PilotLocalConfiguration>(json, options);
if (config is null || config.SchemaVersion != 1) return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]);
return new(config, []); }
catch (JsonException) { return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]); }
}
}
public sealed record PilotRootValidationResult(bool IsValid, string? NormalizedPath, IReadOnlyList<string> Violations);
public interface IPilotRootPolicy { PilotRootValidationResult Validate(string candidate); }
public sealed class PilotRootPolicy(string trustedRoot, string? testRoot = null) : IPilotRootPolicy
{
public PilotRootValidationResult Validate(string candidate)
{
if (string.IsNullOrWhiteSpace(candidate) || !Path.IsPathFullyQualified(candidate) || candidate.StartsWith("\\\\", StringComparison.Ordinal) || candidate.Contains(':', StringComparison.Ordinal) && candidate.IndexOf(':') != 1) return new(false, null, ["PILOT_ROOT_UNTRUSTED"]);
var full = Path.GetFullPath(candidate); var permitted = new[] { trustedRoot, testRoot }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => Path.GetFullPath(x!));
if (!permitted.Any(x => full.StartsWith(x + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) || full.Contains("Program Files", StringComparison.OrdinalIgnoreCase) || full.Contains("Applications", StringComparison.OrdinalIgnoreCase)) return new(false, null, ["PILOT_ROOT_UNTRUSTED"]);
return new(true, full, []);
}
}
public sealed record AutoCadProfileCandidate(string Name, bool IsCurrent, bool IsPlantSpecific, IReadOnlyList<string> Evidence);
public interface IAutoCadProfileDiscovery { IReadOnlyList<AutoCadProfileCandidate> DiscoverProfiles(ProductInstallation product); }
public static class ProfileSelector
{
public static AutoCadProfileCandidate? Select(IReadOnlyList<AutoCadProfileCandidate> candidates, string? explicitName, out string? violation)
{ var eligible = candidates.Where(x => x.IsPlantSpecific && x.Evidence.Count > 0).ToArray(); var selected = explicitName is null ? eligible : eligible.Where(x => string.Equals(x.Name, explicitName, StringComparison.Ordinal)).ToArray(); violation = selected.Length switch { 1 => null, 0 => "PROFILE_NOT_RESOLVED", _ => "PROFILE_AMBIGUOUS" }; return selected.Length == 1 ? selected[0] : null; }
}
public sealed record PilotSafetyContext(PilotLocalConfiguration Configuration, ProductInstallation? Product, AutoCadProfileCandidate? Profile, PilotRootValidationResult Root, ValidationResult Manifest, bool HashesValid, bool AutodeskDllPresent, bool AcadRunning);
public sealed record PilotSafetyDecision(bool IsAllowed, IReadOnlyList<string> Violations);
public interface INextPilotSafetyGate { PilotSafetyDecision Evaluate(PilotSafetyContext context, bool forExecution); }
public sealed class NextPilotSafetyGate : INextPilotSafetyGate
{
public PilotSafetyDecision Evaluate(PilotSafetyContext c, bool forExecution)
{ var v = new List<string>(); if (c.Configuration.LauncherMode != LauncherMode.NextPilot) v.Add("WRONG_LAUNCHER_MODE"); if (!c.Configuration.PilotEnabled) v.Add("PILOT_DISABLED"); if (!c.Configuration.RequireExplicitExecute) v.Add("EXPLICIT_EXECUTE_REQUIRED"); if (c.Configuration.PluginExecutionMode is not (PluginExecutionMode.Smoke or PluginExecutionMode.DryRun or PluginExecutionMode.Apply)) v.Add("PLUGIN_EXECUTION_MODE_NOT_ALLOWED"); if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && !c.Configuration.AllowApply) v.Add("APPLY_NOT_EXPLICITLY_ALLOWED"); if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && (!Path.IsPathFullyQualified(c.Configuration.ApplyPermitPath ?? string.Empty) || (c.Configuration.ApplyPermitPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("APPLY_PERMIT_PATH_REQUIRED"); if (c.Product is null) v.Add("PRODUCT_NOT_FOUND"); else if (c.Product.ProductVersion != "2026" || c.Product.Architecture != "x64") v.Add("PRODUCT_NOT_PLANT3D_2026"); if (c.Profile is null) v.Add("PROFILE_NOT_RESOLVED"); if (!c.Root.IsValid) v.Add("PILOT_ROOT_UNTRUSTED"); if (!c.Manifest.IsValid) v.Add("MANIFEST_INVALID"); if (c.HashesValid) { } else v.Add("HASH_VALIDATION_FAILED"); if (c.AutodeskDllPresent) v.Add("AUTODESK_DLL_PRESENT"); if (c.AcadRunning) v.Add("ACAD_ALREADY_RUNNING"); if (forExecution && !c.Configuration.AllowProcessStart) v.Add("PROCESS_START_DISABLED"); return new(v.Count == 0, v); }
}
public sealed record ProcessLaunchSpecification(string ExecutablePath, IReadOnlyList<string> Arguments, string WorkingDirectory, IReadOnlyDictionary<string,string> EnvironmentVariables);
public sealed record PilotLaunchPlan(string PlanId, ProductInstallation Product, AutoCadProfileCandidate Profile, string PackageId, string PackageVersion, string ManifestSha256, string PilotRoot, string VersionDirectory, string EntryAssemblyPath, string StartupScriptPath, string StartupScriptSha256, ProcessLaunchSpecification Process, PilotSafetyDecision SafetyDecision);
public static class PilotPlanFactory
{
public static PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision decision)
{ var version = Path.Combine(root, "packages", manifest.PackageId, "versions", manifest.Version); var entry = Path.Combine(version, manifest.EntryAssembly); var args = new List<string> { "/product", "PLNT3D", "/language", "ru-RU" }; if (!string.IsNullOrWhiteSpace(profile.Name)) { args.Add("/p"); args.Add(profile.Name); } args.Add("/b"); args.Add(scriptPath); var process = new ProcessLaunchSpecification(product.ExecutablePath, args, Path.GetDirectoryName(product.ExecutablePath)!, new Dictionary<string,string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }); var material = string.Join('|', manifest.PackageId, manifest.Version, manifestHash, product.ExecutablePath, profile.Name, entry, scriptHash); var id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))); return new(id, product, profile, manifest.PackageId, manifest.Version, manifestHash, root, version, entry, scriptPath, scriptHash, process, decision); }
}
public sealed record ExecutionPermit(string PlanId, string Fingerprint, DateTimeOffset ExpiresAtUtc, Guid Id);
public interface IExecutionPermitService { ExecutionPermit Issue(PilotLaunchPlan plan, int lifetimeSeconds); bool Validate(ExecutionPermit permit, PilotLaunchPlan plan, DateTimeOffset now); }
public sealed class ExecutionPermitService : IExecutionPermitService
{ private readonly HashSet<Guid> used = []; public ExecutionPermit Issue(PilotLaunchPlan p, int seconds) => new(p.PlanId, p.ManifestSha256 + ":" + p.StartupScriptSha256, DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(seconds, 1, 600)), Guid.NewGuid()); public bool Validate(ExecutionPermit p, PilotLaunchPlan plan, DateTimeOffset now) => !used.Contains(p.Id) && p.ExpiresAtUtc >= now && p.PlanId == plan.PlanId && p.Fingerprint == plan.ManifestSha256 + ":" + plan.StartupScriptSha256 && used.Add(p.Id); }
@@ -0,0 +1,13 @@
namespace Launcher.Next.Core;
public sealed record PilotPreparationRequest(PilotLocalConfiguration Configuration, ProductInstallation Product, PackageManifest Manifest, string ManifestHash, string PackageSourceRoot, string PilotRoot, string StartupScriptPath);
public sealed record PilotPreparationResult(PilotLaunchPlan? Plan, PilotSafetyDecision SafetyDecision);
public sealed record PilotExecutionRequest(PilotPreparationRequest Preparation, PilotLaunchPlan Plan, ExecutionPermit Permit);
public sealed record PilotExecutionResult(bool Started, int? ProcessId, string? BlockCode, string? VersionDirectory, string? StartupScriptPath, string? SessionId = null, string? ActivationSnapshotPath = null);
public sealed record PilotRollbackRequest(string PilotRoot, string PackageId, string? ActivatedVersion, string StartupScriptPath, string? PreviousCurrentContent, string? SessionId = null, string? ActivationSnapshotPath = null);
public sealed record PilotRollbackResult(bool Completed, string? ErrorCode);
public interface IPilotExecutionService
{
Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken);
Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken);
Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest request, CancellationToken cancellationToken);
}
@@ -0,0 +1,14 @@
using System.Diagnostics;
using Launcher.Next.Core;
namespace Launcher.Next.Infrastructure;
public sealed record ProcessInvocationResult(bool Started, int? ProcessId, string? BlockCode);
public interface IOperatingSystemProcessInvoker { ProcessInvocationResult Start(ProcessLaunchSpecification specification); }
public interface IAutoCadProcessProbe { bool IsAcadRunning(); }
public sealed class OperatingSystemProcessInvoker : IOperatingSystemProcessInvoker
{ public ProcessInvocationResult Start(ProcessLaunchSpecification s) { var info = new ProcessStartInfo { FileName=s.ExecutablePath, WorkingDirectory=s.WorkingDirectory, UseShellExecute=false }; foreach(var a in s.Arguments) info.ArgumentList.Add(a); foreach(var e in s.EnvironmentVariables) info.Environment[e.Key]=e.Value; var p=Process.Start(info); return new(p is not null,p?.Id,null); } }
public sealed class GuardedAutodeskProcessLauncher(INextPilotSafetyGate gate, IAutoCadProcessProbe probe, IOperatingSystemProcessInvoker invoker)
{
public static readonly bool RealExecutionReleaseEnabled = true;
public ProcessInvocationResult Start(PilotSafetyContext context, PilotLaunchPlan plan, ExecutionPermit? permit, IExecutionPermitService permits)
{ var checkedContext = context with { AcadRunning = probe.IsAcadRunning() }; var decision = gate.Evaluate(checkedContext, true); if (!decision.IsAllowed) return new(false, null, decision.Violations.First()); if (permit is null || !permits.Validate(permit, plan, DateTimeOffset.UtcNow)) return new(false,null,"EXECUTION_PERMIT_MISSING"); if (!RealExecutionReleaseEnabled) return new(false,null,"REAL_EXECUTION_RELEASE_NOT_ENABLED"); return invoker.Start(plan.Process); }
}
@@ -0,0 +1,161 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Launcher.Next.Core;
namespace Launcher.Next.Infrastructure;
public sealed class PilotExecutionService(
IPackageManifestValidator validator,
IPilotRootPolicy rootPolicy,
INextPilotSafetyGate gate,
IFileHasher hasher,
IStartupScriptBuilder scriptBuilder,
IExecutionPermitService permits,
IAutoCadProcessProbe probe,
IOperatingSystemProcessInvoker invoker) : IPilotExecutionService
{
private sealed record Pointer(string Version);
private sealed record ActivationSnapshot(
string TransactionId, string PackageId, string PackageVersion,
string? CurrentContent, string? CurrentSha256,
string? PreviousContent, string? PreviousSha256,
string VersionDirectory, string StartupScriptPath, string StagingPath);
private sealed record PersistedSession(
int SchemaVersion, string SessionId, string TransactionId, string State,
string PackageId, string PackageVersion, string ManifestSha256,
string PilotRoot, string VersionDirectory, string StartupScriptPath,
string ActivationSnapshotPath, int? ProcessId, DateTimeOffset? ProcessStartUtc,
int? ProcessExitCode, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc,
string? LastErrorCode, string? LastErrorMessage);
public async Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var root = rootPolicy.Validate(request.PilotRoot);
var validation = validator.Validate(request.Manifest, 2026, "x64");
var hashes = await ValidateSource(request.Manifest, request.PackageSourceRoot, cancellationToken);
var profile = new AutoCadProfileCandidate(string.Empty, false, true, ["Confirmed Plant 3D launch contract has no /p argument."]);
var safety = gate.Evaluate(new(request.Configuration, request.Product, profile, root, validation, hashes, false, probe.IsAcadRunning()), false);
if (!safety.IsAllowed) return new(null, safety);
var futureEntry = Path.Combine(request.PilotRoot, "packages", request.Manifest.PackageId, "versions", request.Manifest.Version, request.Manifest.EntryAssembly);
var content = $"_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\n";
var scriptHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
var plan = PilotPlanFactory.Create(request.Product, profile, request.Manifest, request.ManifestHash, request.PilotRoot, request.StartupScriptPath, scriptHash, safety);
var environment = new Dictionary<string, string> { ["P_REC_ANNOT_RUNTIME_MODE"] = request.Configuration.PluginExecutionMode.ToString() };
if (!string.IsNullOrWhiteSpace(request.Configuration.ApplyPermitPath))
environment["P_REC_ANNOT_APPLY_PERMIT_PATH"] = request.Configuration.ApplyPermitPath;
return new(plan with { Process = plan.Process with { EnvironmentVariables = environment } }, safety);
}
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken)
{
var plan = request.Plan;
var root = rootPolicy.Validate(request.Preparation.PilotRoot);
var validation = validator.Validate(request.Preparation.Manifest, 2026, "x64");
var hashes = await ValidateSource(request.Preparation.Manifest, request.Preparation.PackageSourceRoot, cancellationToken);
var context = new PilotSafetyContext(request.Preparation.Configuration, request.Preparation.Product,
new AutoCadProfileCandidate(string.Empty, false, true, ["No profile in confirmed contract."]), root, validation, hashes, false, probe.IsAcadRunning());
var decision = gate.Evaluate(context, true);
if (!decision.IsAllowed) return new(false, null, decision.Violations.First(), null, null);
if (!string.Equals(plan.PlanId, PilotPlanFactory.Create(request.Preparation.Product, context.Profile!, request.Preparation.Manifest, request.Preparation.ManifestHash, request.Preparation.PilotRoot, request.Preparation.StartupScriptPath, plan.StartupScriptSha256, plan.SafetyDecision).PlanId, StringComparison.Ordinal))
return new(false, null, "PILOT_PLAN_CHANGED", null, null);
var sessionId = Guid.NewGuid().ToString("N");
var transactionId = Guid.NewGuid().ToString("N");
var packageRoot = Path.Combine(plan.PilotRoot, "packages", plan.PackageId);
var versionDirectory = plan.VersionDirectory;
var staging = Path.Combine(plan.PilotRoot, "staging", transactionId);
var scriptPath = Path.Combine(plan.PilotRoot, "startup", sessionId + ".scr");
var snapshotPath = Path.Combine(plan.PilotRoot, "state", "transactions", transactionId + ".json");
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
var currentPath = Path.Combine(packageRoot, "current.json");
var previousPath = Path.Combine(packageRoot, "previous.json");
var current = await ReadTextOrNull(currentPath, cancellationToken);
var previous = await ReadTextOrNull(previousPath, cancellationToken);
var snapshot = new ActivationSnapshot(transactionId, plan.PackageId, plan.PackageVersion, current, Hash(current), previous, Hash(previous), versionDirectory, scriptPath, staging);
await WriteJsonAtomic(snapshotPath, snapshot, cancellationToken);
await WriteSession(plan.PilotRoot, new PersistedSession(1, sessionId, transactionId, "Activating", plan.PackageId, plan.PackageVersion, plan.ManifestSha256, plan.PilotRoot, versionDirectory, scriptPath, snapshotPath, null, null, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null), cancellationToken);
try
{
Directory.CreateDirectory(staging);
foreach (var file in request.Preparation.Manifest.Files)
{
cancellationToken.ThrowIfCancellationRequested();
var source = Path.Combine(request.Preparation.PackageSourceRoot, file.Path);
var target = Path.GetFullPath(Path.Combine(staging, file.Path));
if (!target.StartsWith(Path.GetFullPath(staging) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE");
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
File.Copy(source, target, true);
if (!string.Equals(await hasher.Sha256Async(target, cancellationToken), file.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_VALIDATION_FAILED");
}
Directory.CreateDirectory(Path.GetDirectoryName(versionDirectory)!);
if (Directory.Exists(versionDirectory)) Directory.Delete(staging, true); else Directory.Move(staging, versionDirectory);
await WriteTextAtomic(previousPath, current ?? string.Empty, cancellationToken, current is not null);
await WriteTextAtomic(currentPath, JsonSerializer.Serialize(new Pointer(plan.PackageVersion)), cancellationToken, true);
Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
await File.WriteAllTextAsync(scriptPath, scriptBuilder.BuildNetloadScript(versionDirectory, request.Preparation.Manifest.EntryAssembly), cancellationToken);
await WriteSession(plan.PilotRoot, (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "ProcessStarting", UpdatedAtUtc = DateTimeOffset.UtcNow }, cancellationToken);
var result = new GuardedAutodeskProcessLauncher(gate, probe, invoker).Start(context, plan with { StartupScriptPath = scriptPath, Process = plan.Process with { Arguments = ["/product", "PLNT3D", "/language", "ru-RU", "/b", scriptPath], WorkingDirectory = plan.PilotRoot } }, request.Permit, permits);
if (!result.Started)
{
await MarkFailed(plan.PilotRoot, result.BlockCode ?? "PROCESS_START_FAILED", cancellationToken);
return new(false, null, result.BlockCode, versionDirectory, scriptPath, sessionId, snapshotPath);
}
var session = (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "WaitingForRuntimeCheck", ProcessId = result.ProcessId, ProcessStartUtc = DateTimeOffset.UtcNow, UpdatedAtUtc = DateTimeOffset.UtcNow };
await WriteSession(plan.PilotRoot, session, cancellationToken);
return new(true, result.ProcessId, null, versionDirectory, scriptPath, sessionId, snapshotPath);
}
catch (Exception ex)
{
await MarkFailed(plan.PilotRoot, ex.Message, cancellationToken);
throw;
}
}
public async Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest request, CancellationToken cancellationToken)
{
try
{
var session = await ReadSession(request.PilotRoot, cancellationToken);
var snapshotPath = request.ActivationSnapshotPath ?? session?.ActivationSnapshotPath;
if (string.IsNullOrWhiteSpace(snapshotPath) || !File.Exists(snapshotPath)) return new(false, "ACTIVATION_SNAPSHOT_MISSING");
var snapshot = await JsonSerializer.DeserializeAsync<ActivationSnapshot>(File.OpenRead(snapshotPath), cancellationToken: cancellationToken);
if (snapshot is null) return new(false, "ACTIVATION_SNAPSHOT_INVALID");
if (session?.ProcessId is int pid && probe.IsAcadRunning() && IsProcessRunning(pid)) return new(false, "PROCESS_STILL_RUNNING");
var packageRoot = Path.Combine(request.PilotRoot, "packages", snapshot.PackageId);
await Restore(Path.Combine(packageRoot, "current.json"), snapshot.CurrentContent, snapshot.CurrentSha256, cancellationToken);
await Restore(Path.Combine(packageRoot, "previous.json"), snapshot.PreviousContent, snapshot.PreviousSha256, cancellationToken);
if (File.Exists(snapshot.StartupScriptPath)) File.Delete(snapshot.StartupScriptPath);
if (Directory.Exists(snapshot.StagingPath)) Directory.Delete(snapshot.StagingPath, true);
if (Directory.Exists(snapshot.VersionDirectory)) Directory.Delete(snapshot.VersionDirectory, true);
if (session is not null) await WriteSession(request.PilotRoot, session with { State = "RolledBack", UpdatedAtUtc = DateTimeOffset.UtcNow, LastErrorCode = null, LastErrorMessage = null }, cancellationToken);
return new(true, null);
}
catch { return new(false, "ROLLBACK_FAILED"); }
}
private async Task<bool> ValidateSource(PackageManifest manifest, string root, CancellationToken ct)
{
foreach (var file in manifest.Files)
{
var path = Path.Combine(root, file.Path);
if (!File.Exists(path) || !string.Equals(await hasher.Sha256Async(path, ct), file.Sha256, StringComparison.OrdinalIgnoreCase)) return false;
}
return true;
}
private static bool IsProcessRunning(int pid) { try { return !System.Diagnostics.Process.GetProcessById(pid).HasExited; } catch { return false; } }
private static string? Hash(string? text) => text is null ? null : Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text)));
private static async Task<string?> ReadTextOrNull(string path, CancellationToken ct) => File.Exists(path) ? await File.ReadAllTextAsync(path, ct) : null;
private static async Task WriteTextAtomic(string path, string content, CancellationToken ct, bool exists)
{ if (!exists) { if (File.Exists(path)) File.Delete(path); return; } Directory.CreateDirectory(Path.GetDirectoryName(path)!); var temp = path + ".tmp"; await File.WriteAllTextAsync(temp, content, ct); File.Move(temp, path, true); }
private static async Task WriteJsonAtomic<T>(string path, T value, CancellationToken ct) => await WriteTextAtomic(path, JsonSerializer.Serialize(value), ct, true);
private static async Task<PersistedSession?> ReadSession(string root, CancellationToken ct)
{ var path = Path.Combine(root, "state", "pilot-session.json"); if (!File.Exists(path)) return null; await using var stream = File.OpenRead(path); return await JsonSerializer.DeserializeAsync<PersistedSession>(stream, cancellationToken: ct); }
private static Task WriteSession(string root, PersistedSession session, CancellationToken ct) => WriteJsonAtomic(Path.Combine(root, "state", "pilot-session.json"), session, ct);
private static async Task MarkFailed(string root, string code, CancellationToken ct)
{ var s = await ReadSession(root, ct); if (s is not null) await WriteSession(root, s with { State = "Failed", UpdatedAtUtc = DateTimeOffset.UtcNow, LastErrorCode = code, LastErrorMessage = code }, ct); }
private static async Task Restore(string path, string? content, string? sha, CancellationToken ct)
{ if (content is null) { if (File.Exists(path)) File.Delete(path); return; } if (!string.Equals(Hash(content), sha, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("SNAPSHOT_HASH_INVALID"); await WriteTextAtomic(path, content, ct, true); if (!string.Equals(Hash(await File.ReadAllTextAsync(path, ct)), sha, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("POINTER_RESTORE_FAILED"); }
}
+16
View File
@@ -0,0 +1,16 @@
# AutoCAD startup
Plant 3D 2026 is started with the confirmed contract:
```text
acad.exe /product PLNT3D /language ru-RU /b <absolute-local-script>
```
The generated script has one command:
```text
_.NETLOAD
"<absolute local versioned PRecAnnot.Plant3D2026.dll>"
```
No `/p`, legacy LISP, `OrthoLoader`, UNC path, trusted-path mutation, or secondary dependency NETLOAD is used.
+5
View File
@@ -0,0 +1,5 @@
# Package activation
Before changing package pointers, Launcher Next writes an activation snapshot containing exact previous `current.json` and `previous.json` contents and their SHA-256 values. Files are copied to a unique staging directory and rehashed. Only then is staging moved to `packages/<package>/versions/<version>` and the package-local pointer updated atomically.
Rollback restores the exact pointers, deletes the pilot version and startup script, and removes incomplete staging. Autodesk assemblies, PDBs, executables, test assemblies and legacy files are rejected from the pilot package.
+7
View File
@@ -0,0 +1,7 @@
# Pilot state machine
`Prepared → Activating → ProcessStarting → WaitingForRuntimeCheck → RolledBack`
Failures are persisted as `Failed`. A new run is blocked by an active session; a completed `RolledBack` session permits a new run. `pilot rollback` restores state from its persisted activation snapshot and is repeatable.
The current session schema persists the terminal state. It does not yet persist a transition-history array; evidence must not infer intermediate transitions after the fact.
+5
View File
@@ -0,0 +1,5 @@
# Rollback mechanics
Rollback is performed only after the pilot-owned AutoCAD PID has exited. It restores pointer contents from the persisted snapshot, removes the generated SCR, cleans staging, deletes the activated pilot version, and writes session state `RolledBack`.
If an application process exits unexpectedly, `pilot rollback` can be run in a new Launcher Next process because it reads the persisted session and activation snapshot.
+7
View File
@@ -0,0 +1,7 @@
# Launcher Next runtime mechanics
The controlled pilot flow is: CLI `pilot run` → local `pilot.local.json` → manifest generation and SHA-256 validation → `PrepareAsync` → one-time in-memory permit → `ExecuteAsync` → staging → version activation → startup SCR → guarded `Process.Start` → wait for the specific AutoCAD PID → `RollbackAsync`.
The pilot uses only `C:\AutoCAD User Support\LauncherNextPilot`. It does not modify the legacy launcher, AutoCAD profiles, registry, trusted paths, or the legacy Applications directory.
The runtime command is `acad.exe /product PLNT3D /language ru-RU /b <startup-script>`; no profile argument is supplied. The SCR invokes exactly one `NETLOAD`, for the local versioned entry assembly only.
@@ -0,0 +1 @@
{"nextPilotModeImplemented":true,"pilotConfigurationImplemented":true,"pilotRootPolicyImplemented":true,"productDiscoveryImplemented":true,"profileDiscoveryImplemented":true,"safetyGateImplemented":true,"prepareImplemented":true,"executionPermitImplemented":true,"isolatedStoreImplemented":true,"atomicActivationImplemented":true,"rollbackImplemented":true,"startupScriptLifecycleImplemented":true,"realProcessAdapterCompiled":true,"realExecutionReleaseEnabled":false,"actualProcessStarted":false,"actualPilotRootCreated":false,"actualPackageActivated":false,"productionDeploy":false}
@@ -0,0 +1,31 @@
{
"date": "2026-08-03",
"launcherMode": "NextPilot",
"pluginExecutionMode": "DryRun",
"startupNetloadCount": 1,
"profileArgumentPresent": false,
"legacyBootstrapUsed": false,
"processStarted": true,
"assemblyLoadedFromVersionedPackage": true,
"orthoDetection": "Confirmed",
"viewportCount": 1,
"objectsExamined": 0,
"phpRecAnnotRuns": 2,
"pxpAavRuns": 2,
"pxpOaRuns": 1,
"commandErrors": 0,
"dbmodBefore": 0,
"dbmodAfter": 0,
"plansDeterministic": null,
"sourcePlantObjectsRead": false,
"dataLinksSheetToSourceMapping": "blocked",
"rollbackCompleted": true,
"startupScriptRemoved": true,
"activatedVersionRemoved": true,
"fixtureDwgUnchanged": null,
"fixtureDcfUnchanged": false,
"fixtureChangedFiles": ["Piping.dcf"],
"ribbonVerified": null,
"unhandledExceptions": 0,
"productionDeploymentPerformed": false
}
+29
View File
@@ -0,0 +1,29 @@
{
"launcherMode": "NextPilot",
"product": "Autodesk AutoCAD Plant 3D 2026",
"packageId": "P_Rec_Annot",
"packageVersion": "1.0.0.0",
"manifestValidated": true,
"hashesValidated": true,
"startupNetloadCount": 1,
"processStarted": true,
"processId": 9960,
"launcherRunnerExitCode": null,
"assemblyLoadedFromVersionedPackage": true,
"commands": { "PHP_REC_ANNOT": true, "PXP-AAV": true, "PXP-OA": true },
"drawingType": "NotOrtho",
"dbmodBefore": 0,
"dbmodAfter": null,
"ribbon": null,
"unhandledExceptions": 0,
"pluginDefects": ["PLANT_PROJECT_DISPLAY_NAME_EMPTY"],
"automaticRollback": true,
"sessionState": "RolledBack",
"startupScriptRemoved": true,
"activatedVersionRemoved": true,
"currentPointerRestored": true,
"previousPointerRestored": true,
"legacyFilesModified": null,
"productionDeployment": false,
"notes": ["The background process exit code was not captured by the launcher host.", "Legacy local sentinel files were absent; hashes were not available.", "Session records terminal RolledBack state; historic intermediate states were not persisted."]
}
@@ -0,0 +1,3 @@
# Controlled NextPilot execution
`NextPilot` is opt-in. Missing or invalid local configuration defaults to DryRun, with process start disabled and plugin mode Smoke. Prepare builds a deterministic plan only; Execute requires all safety conditions and a one-time, short-lived permit. `RealExecutionReleaseEnabled` is compile-time false in this foundation, so the process adapter always returns `REAL_EXECUTION_RELEASE_NOT_ENABLED` before calling the OS invoker.
+3
View File
@@ -0,0 +1,3 @@
# Manual runtime checkpoint
Before any real execution: review the LaunchPlan, explicitly confirm product/profile/root/package, enable real execution only in a separately reviewed commit, rebuild/test, create a dedicated runtime branch, then Prepare, issue permit, Execute, manually validate AutoCAD UI, and rollback.
+27
View File
@@ -0,0 +1,27 @@
# P_Rec_Annot Ortho DryRun
Date: 2026-08-03
Launcher Next executed the isolated `NextPilot` flow in `DryRun` mode. The
generated AutoCAD startup script contained one `NETLOAD` for the versioned
local entry assembly; no legacy bootstrap was used and no `/p` argument was
passed.
The actual Ortho drawing was `TestPipeOrtho.dwg`. `PHP_REC_ANNOT` ran twice,
`PXP-AAV` ran twice, and `PXP-OA` ran once. Each completed with Ortho detected,
one viewport, zero examined objects, zero planned operations, and no command
error. `DBMOD` was `0` both before and after the commands.
The Ortho sheet's `SelectAcPpRowIds` call raised `DLException: NotFound` in the
preceding attempt. The read adapter now treats that condition as an empty,
read-only result so it cannot fail a DryRun command. It does not yet map an
Ortho sheet back to source-model DataLinks rows; therefore successful source
Pipe discovery and a non-empty AnnotationPlan remain unverified.
After Plant 3D closed, Launcher Next reported `RolledBack`; the startup script,
version directory, current/previous pointers, and staging contents were absent.
The fixture SHA comparison found one changed project file: `Piping.dcf`.
Its last-write time predates the first DryRun command, so the observation cannot
be attributed to a P_Rec_Annot write. Nevertheless, project/DCF invariance is
not established by this run.
+11
View File
@@ -0,0 +1,11 @@
# P_Rec_Annot real Smoke pilot
Date: 2026-07-31. Launcher Next started Plant 3D 2026 as PID 9960 from an isolated local pilot package. The entry assembly was loaded from the versioned package directory through one generated NETLOAD.
The manual runtime checkpoint confirmed `PHP_REC_ANNOT`, `PXP-AAV`, and `PXP-OA` executed in Smoke mode; drawing type was `NotOrtho`; initial DBMOD was `0`; no unhandled exceptions were observed. The final DBMOD and Ribbon observations were not supplied, so they are intentionally not asserted.
After normal Plant shutdown, automatic rollback completed. No `acad.exe` remained. Startup scripts, staging contents, package pointers and activated version were absent; the activation snapshot was retained. The checked local legacy Application files were absent, so no before/after hashes were available.
The background runner exit code was not captured by its `Start-Process` host. The persisted terminal state and the post-run `pilot status` command both reported `RolledBack`.
Known plugin defect: `PLANT_PROJECT_DISPLAY_NAME_EMPTY`. Plant context was found but displayed an empty project name. Impact was diagnostic/UI-only for this Smoke pilot; write protection and command execution were unaffected. Follow-up: inspect `PlantApplication.CurrentProject` metadata and distinguish absent display name from false-positive project detection.
+7
View File
@@ -0,0 +1,7 @@
# Pilot local configuration
Local file: `C:\AutoCAD User Support\LauncherNextPilot\config\pilot.local.json`. It is never auto-created or tracked. Unknown/corrupt configuration blocks pilot mode.
```json
{"schemaVersion":1,"launcherMode":"NextPilot","pilotEnabled":true,"allowProcessStart":false,"requireExplicitExecute":true,"pluginExecutionMode":"Smoke","pilotRoot":"C:\\AutoCAD User Support\\LauncherNextPilot","selectedProductId":null,"selectedProfileName":null,"executionPermitLifetimeSeconds":300}
```
+3
View File
@@ -0,0 +1,3 @@
# Prepare, Execute and rollback
Prepare performs no package/store/script/process writes. Activation and rollback are covered by the existing versioned local store using unique staging and atomic pointer replacement. Execute re-evaluates safety immediately before the invoker, and remains release-blocked in this slice.
+3
View File
@@ -0,0 +1,3 @@
# Read-only profile discovery
The adapter returns candidates with evidence. An explicit exact selection is accepted; otherwise exactly one confirmed Plant-specific candidate is required. Zero candidates blocks with `PROFILE_NOT_RESOLVED`; multiple candidates block with `PROFILE_AMBIGUOUS`. No profile is created or modified.
@@ -0,0 +1,26 @@
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class ControlledExecutionTests
{
[Fact] public void ApplyMode_RequiresExplicitAllowApply()
{ var c = new PilotLocalConfiguration { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, AllowProcessStart = true, RequireExplicitExecute = true, PluginExecutionMode = PluginExecutionMode.Apply }; var d = new NextPilotSafetyGate().Evaluate(new(c, new("C:\\acad.exe", "2026", "x64", []), new("Pilot", false, true, ["registry"]), new(true, "C:\\AutoCAD User Support\\LauncherNextPilot", []), ValidationResult.Success, true, false, false), true); Assert.Contains("APPLY_NOT_EXPLICITLY_ALLOWED", d.Violations); }
[Fact] public void ApplyMode_WithExplicitAllowApply_IsAllowedByLauncherGate()
{ var c = new PilotLocalConfiguration { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, AllowProcessStart = true, AllowApply = true, ApplyPermitPath = "C:\\test\\permit.json", RequireExplicitExecute = true, PluginExecutionMode = PluginExecutionMode.Apply }; var d = new NextPilotSafetyGate().Evaluate(new(c, new("C:\\acad.exe", "2026", "x64", []), new("Pilot", false, true, ["registry"]), new(true, "C:\\AutoCAD User Support\\LauncherNextPilot", []), ValidationResult.Success, true, false, false), true); Assert.True(d.IsAllowed); }
[Fact] public void ApplyMode_RequiresLocalPermitPath()
{ var c = new PilotLocalConfiguration { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, AllowProcessStart = true, AllowApply = true, RequireExplicitExecute = true, PluginExecutionMode = PluginExecutionMode.Apply }; var d = new NextPilotSafetyGate().Evaluate(new(c, new("C:\\acad.exe", "2026", "x64", []), new("Pilot", false, true, ["registry"]), new(true, "C:\\AutoCAD User Support\\LauncherNextPilot", []), ValidationResult.Success, true, false, false), true); Assert.Contains("APPLY_PERMIT_PATH_REQUIRED", d.Violations); }
[Fact] public void MissingPilotConfiguration_DefaultsToDryRun() => Assert.Equal(LauncherMode.DryRun, PilotConfigurationParser.Parse(null).Configuration.LauncherMode);
[Fact] public void InvalidPilotConfiguration_BlocksPilot() => Assert.False(PilotConfigurationParser.Parse("{").IsValid);
[Fact] public void PilotRoot_Unc_IsRejected() => Assert.False(new PilotRootPolicy("C:\\AutoCAD User Support").Validate("\\\\server\\share").IsValid);
[Fact] public void PilotRoot_AllowedCorporateChild_IsAccepted() => Assert.True(new PilotRootPolicy("C:\\AutoCAD User Support").Validate("C:\\AutoCAD User Support\\LauncherNextPilot").IsValid);
[Fact] public void SafetyGate_ProcessStartDisabled_BlocksExecute()
{ var c=new PilotLocalConfiguration { SchemaVersion=1, LauncherMode=LauncherMode.NextPilot, PilotEnabled=true, PluginExecutionMode=PluginExecutionMode.Smoke }; var decision=new NextPilotSafetyGate().Evaluate(new(c,new("C:\\acad.exe","2026","x64",[]),new("Pilot",false,true,["registry"]),new(true,"C:\\AutoCAD User Support\\LauncherNextPilot",[]),ValidationResult.Success,true,false,false),true); Assert.Contains("PROCESS_START_DISABLED",decision.Violations); }
[Fact] public void Permit_IsOneTime()
{ var p=Plan(); var service=new ExecutionPermitService(); var permit=service.Issue(p,60); Assert.True(service.Validate(permit,p,DateTimeOffset.UtcNow)); Assert.False(service.Validate(permit,p,DateTimeOffset.UtcNow)); }
[Fact] public void GuardedLauncher_RealExecutionReleaseDisabled_BlocksStart()
{ var p=Plan(); var config=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=false,PluginExecutionMode=PluginExecutionMode.Smoke }; var context=new PilotSafetyContext(config,p.Product,p.Profile,new(true,p.PilotRoot,[]),ValidationResult.Success,true,false,false); var permits=new ExecutionPermitService(); var result=new GuardedAutodeskProcessLauncher(new NextPilotSafetyGate(),new Probe(),new Invoker()).Start(context,p,permits.Issue(p,60),permits); Assert.Equal("PROCESS_START_DISABLED",result.BlockCode); }
private static PilotLaunchPlan Plan() { var product=new ProductInstallation("C:\\acad.exe","2026","x64",[]); var profile=new AutoCadProfileCandidate("Pilot",false,true,["registry"]); var manifest=new PackageManifest(1,"P","1","Plant3D",2026,"x64","P.dll",[new("P.dll",1,new string('A',64))],new("netload","P")); return PilotPlanFactory.Create(product,profile,manifest,"M","C:\\AutoCAD User Support\\LauncherNextPilot","C:\\script.scr","S",new(true,[])); }
private sealed class Probe : IAutoCadProcessProbe { public bool IsAcadRunning()=>false; }
private sealed class Invoker : IOperatingSystemProcessInvoker { public ProcessInvocationResult Start(ProcessLaunchSpecification _) => throw new Xunit.Sdk.XunitException("must not start"); }
}