16 changed files with 469 additions and 14 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(); }
}
+2 -2
View File
@@ -26,7 +26,7 @@ public static class PilotConfigurationParser
public static PilotConfigurationLoadResult Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new(PilotLocalConfiguration.SafeDefault, []);
try { var config = JsonSerializer.Deserialize<PilotLocalConfiguration>(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow });
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"]); }
@@ -57,7 +57,7 @@ public interface INextPilotSafetyGate { PilotSafetyDecision Evaluate(PilotSafety
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.PluginExecutionMode != PluginExecutionMode.Smoke) v.Add("NON_SMOKE_PLUGIN_MODE"); 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) 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); }
{ 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)) v.Add("PLUGIN_EXECUTION_MODE_NOT_ALLOWED"); 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) 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);
@@ -2,8 +2,8 @@ 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);
public sealed record PilotRollbackRequest(string PilotRoot, string PackageId, string? ActivatedVersion, string StartupScriptPath, string? PreviousCurrentContent);
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
{
@@ -1,13 +1,158 @@
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
public sealed class PilotExecutionService(
IPackageManifestValidator validator,
IPilotRootPolicy rootPolicy,
INextPilotSafetyGate gate,
IFileHasher hasher,
IStartupScriptBuilder scriptBuilder,
IExecutionPermitService permits,
IAutoCadProcessProbe probe,
IOperatingSystemProcessInvoker invoker) : IPilotExecutionService
{
public Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest r, CancellationToken ct)
{ ct.ThrowIfCancellationRequested(); var root=rootPolicy.Validate(r.PilotRoot); var validation=validator.Validate(r.Manifest,2026,"x64"); var hashes=ValidateSource(r.Manifest,r.PackageSourceRoot,ct).GetAwaiter().GetResult(); var profile=new AutoCadProfileCandidate("",false,true,["legacy contract: no profile argument"]); var safety=gate.Evaluate(new(r.Configuration,r.Product,profile,root,validation,hashes,false,probe.IsAcadRunning()),false); if(!safety.IsAllowed)return Task.FromResult(new PilotPreparationResult(null,safety)); var futureEntry=Path.Combine(r.PilotRoot,"packages",r.Manifest.PackageId,"versions",r.Manifest.Version,r.Manifest.EntryAssembly); var content="._NETLOAD\n\""+futureEntry.Replace("\\","\\\\")+"\"\n"; var scriptHash=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))); var plan=PilotPlanFactory.Create(r.Product,profile,r.Manifest,r.ManifestHash,r.PilotRoot,r.StartupScriptPath,scriptHash,safety); return Task.FromResult(new PilotPreparationResult(plan,safety)); }
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest r, CancellationToken ct)
{ var plan=r.Plan; var root=rootPolicy.Validate(r.Preparation.PilotRoot); var valid=validator.Validate(r.Preparation.Manifest,2026,"x64"); var hashes=await ValidateSource(r.Preparation.Manifest,r.Preparation.PackageSourceRoot,ct); var context=new PilotSafetyContext(r.Preparation.Configuration,r.Preparation.Product,new("",false,true,["legacy contract"]),root,valid,hashes,false,probe.IsAcadRunning()); var safety=gate.Evaluate(context,true); if(!safety.IsAllowed)return new(false,null,safety.Violations.First(),null,null); if(!permits.Validate(r.Permit,plan,DateTimeOffset.UtcNow))return new(false,null,"EXECUTION_PERMIT_MISSING",null,null); var version=Path.Combine(plan.PilotRoot,"packages",plan.PackageId,"versions",plan.PackageVersion); var staging=Path.Combine(plan.PilotRoot,"staging",Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging); try { foreach(var f in r.Preparation.Manifest.Files){ct.ThrowIfCancellationRequested();var src=Path.Combine(r.Preparation.PackageSourceRoot,f.Path);var dst=Path.Combine(staging,f.Path);Directory.CreateDirectory(Path.GetDirectoryName(dst)!);File.Copy(src,dst,true);if(!string.Equals(await hasher.Sha256Async(dst,ct),f.Sha256,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("HASH_VALIDATION_FAILED");} Directory.CreateDirectory(Path.GetDirectoryName(version)!);if(!Directory.Exists(version))Directory.Move(staging,version);else Directory.Delete(staging,true); Directory.CreateDirectory(Path.GetDirectoryName(plan.StartupScriptPath)!);await File.WriteAllTextAsync(plan.StartupScriptPath,scriptBuilder.BuildNetloadScript(version,r.Preparation.Manifest.EntryAssembly),ct); var guarded=new GuardedAutodeskProcessLauncher(gate,probe,invoker);var result=guarded.Start(context,plan,r.Permit,permits);return new(result.Started,result.ProcessId,result.BlockCode,version,plan.StartupScriptPath);}catch{if(Directory.Exists(staging))Directory.Delete(staging,true);throw;} }
public Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest r,CancellationToken ct){try{if(File.Exists(r.StartupScriptPath))File.Delete(r.StartupScriptPath);if(r.ActivatedVersion is not null){var d=Path.Combine(r.PilotRoot,"packages",r.PackageId,"versions",r.ActivatedVersion);if(Directory.Exists(d))Directory.Delete(d,true);}return Task.FromResult(new PilotRollbackResult(true,null));}catch{return Task.FromResult(new PilotRollbackResult(false,"ROLLBACK_FAILED"));}}
private async Task<bool> ValidateSource(PackageManifest m,string root,CancellationToken ct){foreach(var f in m.Files){var p=Path.Combine(root,f.Path);if(!File.Exists(p)||!string.Equals(await hasher.Sha256Async(p,ct),f.Sha256,StringComparison.OrdinalIgnoreCase))return false;}return true;}
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);
return new(plan with { Process = plan.Process with { EnvironmentVariables = new Dictionary<string, string> { ["P_REC_ANNOT_RUNTIME_MODE"] = request.Configuration.PluginExecutionMode.ToString() } } }, 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,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."]
}
+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.