diff --git a/Launcher.Next.App/PilotCli.cs b/Launcher.Next.App/PilotCli.cs index b977ff7..3e95d54 100644 --- a/Launcher.Next.App/PilotCli.cs +++ b/Launcher.Next.App/PilotCli.cs @@ -20,7 +20,8 @@ internal static class PilotCli { try { - if (args.Length == 0 || args[0] is not ("prepare" or "run" or "status" or "rollback")) return Blocked; + if (args.Length == 0 || args[0] is not ("prepare" or "run" or "status" or "rollback" or "automate-first-apply")) return Blocked; + if (args[0] == "automate-first-apply") return await AutomateFirstApplyAsync(args.Skip(1).ToArray()); 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!; @@ -74,6 +75,62 @@ internal static class PilotCli } private static readonly IExecutionPermitService PermitService = new ExecutionPermitService(); + + private sealed record AutomationOptions(string PackageSource, string EntryAssembly, string PilotRoot, string FixtureMarker, string OrthoDrawing, string ModelDrawing, string EvidenceRoot); + private sealed record PreviewEvidence(bool Completed, string? PlanIdRun1, string? PlanIdRun2, bool? PlanIdsEqual, int? EligibleCandidateCount, string? SelectedCandidateHash); + private sealed record ApplyPermit(bool AllowApply, string FixtureMarkerPath, string TargetSnapshotId, string ExpectedPlanId, string VisibilityState); + + private static async Task AutomateFirstApplyAsync(string[] args) + { + if (!TryParseAutomation(args, out var options, out var error)) return Fail(Blocked, error!); + if (!TryReadConfiguration(options.PilotRoot, out var baseConfig, out error)) return Fail(Blocked, error!); + if (baseConfig.LauncherMode != LauncherMode.NextPilot || !baseConfig.PilotEnabled || !baseConfig.AllowProcessStart || !baseConfig.RequireExplicitExecute) return Fail(Blocked, "AUTOMATION_SAFETY_GATE_REJECTED"); + if (!TryBuildManifest(options.PackageSource, options.EntryAssembly, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!); + if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING"); + Directory.CreateDirectory(options.EvidenceRoot); + var before = ContentHashes(options.FixtureMarker); + var previewConfigPath = Path.Combine(options.EvidenceRoot, "automation-preview.config.json"); + var previewEvidence = Path.Combine(options.EvidenceRoot, "preview-result.json"); + await File.WriteAllTextAsync(previewConfigPath, JsonSerializer.Serialize(new { phase = "Preview", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, evidencePath = previewEvidence, fixtureMarkerPath = options.FixtureMarker })); + var previewConfig = baseConfig with { PluginExecutionMode = PluginExecutionMode.DryRun, AllowApply = false, ApplyPermitPath = null, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = previewConfigPath }; + if (!await RunAutomationSession(previewConfig, manifest!, manifestHash!, options, "preview")) return Fail(ProcessFailed, "AUTOMATION_PREVIEW_SESSION_FAILED"); + var preview = JsonSerializer.Deserialize(await File.ReadAllTextAsync(previewEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + if (preview is null || !preview.Completed || preview.PlanIdRun1 is null || preview.PlanIdRun1 != preview.PlanIdRun2 || preview.PlanIdsEqual != true || preview.EligibleCandidateCount is not > 0 || string.IsNullOrWhiteSpace(preview.SelectedCandidateHash)) return Fail(Blocked, "AUTOMATION_PREVIEW_EVIDENCE_INVALID"); + if (!HashesEqual(before, ContentHashes(options.FixtureMarker))) return Fail(Blocked, "AUTOMATION_PREVIEW_CONTENT_CHANGED"); + var permitPath = Path.Combine(options.EvidenceRoot, "apply-permit.json"); + await File.WriteAllTextAsync(permitPath, JsonSerializer.Serialize(new ApplyPermit(true, options.FixtureMarker, preview.SelectedCandidateHash, preview.PlanIdRun1, "Visible"))); + var applyConfigPath = Path.Combine(options.EvidenceRoot, "automation-apply.config.json"); + var applyEvidence = Path.Combine(options.EvidenceRoot, "apply-result.json"); + await File.WriteAllTextAsync(applyConfigPath, JsonSerializer.Serialize(new { phase = "Apply", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, evidencePath = applyEvidence, fixtureMarkerPath = options.FixtureMarker })); + var applyConfig = baseConfig with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath }; + var started = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply"); + var hashesEqual = HashesEqual(before, ContentHashes(options.FixtureMarker)); + if (File.Exists(permitPath)) File.Delete(permitPath); + return started && hashesEqual ? Success : Fail(ProcessFailed, started ? "AUTOMATION_CONTENT_CHANGED" : "AUTOMATION_APPLY_SESSION_FAILED"); + } + + private static async Task RunAutomationSession(PilotLocalConfiguration config, PackageManifest manifest, string manifestHash, AutomationOptions options, string phase) + { + 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, options.PilotRoot, Path.Combine(options.PilotRoot, "startup", phase + ".scr")); + var prepared = await service.PrepareAsync(request, CancellationToken.None); if (prepared.Plan is null) return false; + var execution = await service.ExecuteAsync(new(request, prepared.Plan, PermitService.Issue(prepared.Plan, config.ExecutionPermitLifetimeSeconds)), CancellationToken.None); if (!execution.Started || execution.ProcessId is null) return false; + try { await WaitForExitAsync(execution.ProcessId.Value); } + finally { await service.RollbackAsync(new(options.PilotRoot, manifest.PackageId, manifest.Version, execution.StartupScriptPath!, null, execution.SessionId, execution.ActivationSnapshotPath), CancellationToken.None); } + return await IsRolledBack(options.PilotRoot); + } + + private static bool TryParseAutomation(string[] args, out AutomationOptions options, out string? error) + { + options = null!; error = null; var d = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < args.Length; i++) { if (i + 1 >= args.Length || !args[i].StartsWith("--") || !d.TryAdd(args[i], args[++i])) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; } } + string? V(string x) => d.GetValueOrDefault(x); + if (d.Keys.Except(new[] { "--package-source", "--entry-assembly", "--pilot-root", "--fixture-marker", "--ortho-dwg", "--model-dwg", "--evidence-root" }).Any() || new[] { V("--package-source"), V("--entry-assembly"), V("--pilot-root"), V("--fixture-marker"), V("--ortho-dwg"), V("--model-dwg"), V("--evidence-root") }.Any(string.IsNullOrWhiteSpace)) { error = "AUTOMATION_ARGUMENT_INVALID"; return false; } + var paths = new[] { V("--package-source")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--evidence-root")! }; if (paths.Any(x => !Path.IsPathFullyQualified(x) || x.StartsWith("\\\\"))) { error = "AUTOMATION_PATH_INVALID"; return false; } + options = new(V("--package-source")!, V("--entry-assembly")!, V("--pilot-root")!, V("--fixture-marker")!, V("--ortho-dwg")!, V("--model-dwg")!, V("--evidence-root")!); return true; + } + private static Dictionary ContentHashes(string marker) { var root = Path.GetDirectoryName(marker)!; return Directory.GetFiles(root, "*", SearchOption.AllDirectories).Where(x => x.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".dcf", StringComparison.OrdinalIgnoreCase) || Path.GetFileName(x).Equals("Project.xml", StringComparison.OrdinalIgnoreCase)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToDictionary(x => Path.GetRelativePath(root,x), x => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(x))), StringComparer.OrdinalIgnoreCase); } + private static bool HashesEqual(IReadOnlyDictionary x, IReadOnlyDictionary y) => x.Count == y.Count && x.All(p => y.TryGetValue(p.Key, out var value) && value == p.Value); 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 RollbackAsync(string root) @@ -129,8 +186,13 @@ internal static class PilotCli 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 { } + var assemblyVersion = "0.1.0-pilot"; + try { assemblyVersion = System.Reflection.AssemblyName.GetAssemblyName(Path.Combine(source, entry)).Version?.ToString() ?? assemblyVersion; } catch { } + // A pilot version directory is immutable for the duration of its activation. + // Bind its name to the validated package content so a rebuilt local bundle + // cannot silently reuse a stale directory with the same assembly version. + var contentIdentity = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("|", list.OrderBy(x => x.Path, StringComparer.OrdinalIgnoreCase).Select(x => $"{x.Path}:{x.Sha256}")))))[..12]; + var version = assemblyVersion + "-" + contentIdentity; 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; diff --git a/tests/Launcher.Next.Tests/FoundationTests.cs b/tests/Launcher.Next.Tests/FoundationTests.cs index d4bcf67..4c7f67a 100644 --- a/tests/Launcher.Next.Tests/FoundationTests.cs +++ b/tests/Launcher.Next.Tests/FoundationTests.cs @@ -36,4 +36,12 @@ public sealed class FoundationTests try { var store = new LocalPackageStore(root, new Sha256FileHasher()); var active = await store.SynchronizeAsync(manifest, source, CancellationToken.None); var packageRoot = Path.Combine(root, "Applications", manifest.PackageId, "versions", active.CurrentVersion); var script = new StartupScriptBuilder().BuildNetloadScript(packageRoot, manifest.EntryAssembly); var launch = new DryRunAutodeskProcessLauncher().Prepare(new LaunchRequest(new("C:\\acad.exe", "2026", "x64", []), "Pilot", script, true)); Assert.False(launch.WouldStart); Assert.Single(System.Text.RegularExpressions.Regex.Matches(script, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast()); } finally { Directory.Delete(root, true); } } + [Fact] public void PilotCli_UsesContentAddressedVersionForImmutableActivation() + { + var root = new DirectoryInfo(AppContext.BaseDirectory); + while (root is not null && !File.Exists(Path.Combine(root.FullName, "Launcher.Next.sln"))) root = root.Parent; + var source = File.ReadAllText(Path.Combine(root!.FullName, "Launcher.Next.App", "PilotCli.cs")); + Assert.Contains("contentIdentity", source); + Assert.Contains("assemblyVersion + \"-\" + contentIdentity", source); + } }