feat: allow forced cleanup after completed automation evidence
This commit is contained in:
@@ -77,14 +77,16 @@ internal static class PilotCli
|
|||||||
private static readonly IExecutionPermitService PermitService = new ExecutionPermitService();
|
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 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 PreviewEvidence(bool Completed, bool? CommandsCompleted, bool? InspectorsCompleted, bool? EvidenceFlushed, string? PlanIdRun1, string? PlanIdRun2, bool? PlanIdsEqual, int? EligibleCandidateCount, string? SelectedCandidateHash);
|
||||||
|
private sealed record ApplyEvidence(bool Completed, bool? CommandsCompleted, bool? InspectorsCompleted, bool? EvidenceFlushed, bool? PermitAccepted, string? ApplyOutcomeRun1, string? ApplyOutcomeRun2, object? Baseline, object? AfterCommand1, object? AfterCommand2, object? AfterUndo);
|
||||||
private sealed record ApplyPermit(bool AllowApply, string FixtureMarkerPath, string TargetSnapshotId, string ExpectedPlanId, string VisibilityState);
|
private sealed record ApplyPermit(bool AllowApply, string FixtureMarkerPath, string TargetSnapshotId, string ExpectedPlanId, string VisibilityState);
|
||||||
|
private sealed record AutomationSessionResult(bool Started, bool GracefulExitVerified, bool ForcedCleanupUsed, bool RollbackVerified, bool EvidenceCompleted);
|
||||||
|
|
||||||
private static async Task<int> AutomateFirstApplyAsync(string[] args)
|
private static async Task<int> AutomateFirstApplyAsync(string[] args)
|
||||||
{
|
{
|
||||||
if (!TryParseAutomation(args, out var options, out var error)) return Fail(Blocked, error!);
|
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 (!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 (baseConfig.LauncherMode != LauncherMode.NextPilot || !baseConfig.PilotEnabled || !baseConfig.AllowProcessStart || !baseConfig.RequireExplicitExecute || !baseConfig.ForceAfterEvidence) 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 (!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");
|
if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING");
|
||||||
Directory.CreateDirectory(options.EvidenceRoot);
|
Directory.CreateDirectory(options.EvidenceRoot);
|
||||||
@@ -93,9 +95,10 @@ internal static class PilotCli
|
|||||||
var previewEvidence = Path.Combine(options.EvidenceRoot, "preview-result.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 }));
|
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 };
|
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 previewSession = await RunAutomationSession(previewConfig, manifest!, manifestHash!, options, "preview", previewEvidence);
|
||||||
|
if (!previewSession.Started || !previewSession.RollbackVerified || !previewSession.EvidenceCompleted) return Fail(ProcessFailed, "AUTOMATION_PREVIEW_SESSION_FAILED");
|
||||||
var preview = JsonSerializer.Deserialize<PreviewEvidence>(await File.ReadAllTextAsync(previewEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
|
var preview = JsonSerializer.Deserialize<PreviewEvidence>(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 (preview is null || !preview.Completed || preview.CommandsCompleted != true || preview.InspectorsCompleted != true || preview.EvidenceFlushed != true || 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");
|
if (!HashesEqual(before, ContentHashes(options.FixtureMarker))) return Fail(Blocked, "AUTOMATION_PREVIEW_CONTENT_CHANGED");
|
||||||
var permitPath = Path.Combine(options.EvidenceRoot, "apply-permit.json");
|
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")));
|
await File.WriteAllTextAsync(permitPath, JsonSerializer.Serialize(new ApplyPermit(true, options.FixtureMarker, preview.SelectedCandidateHash, preview.PlanIdRun1, "Visible")));
|
||||||
@@ -103,21 +106,42 @@ internal static class PilotCli
|
|||||||
var applyEvidence = Path.Combine(options.EvidenceRoot, "apply-result.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 }));
|
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 applyConfig = baseConfig with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath };
|
||||||
var started = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply");
|
var applySession = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply", applyEvidence);
|
||||||
var hashesEqual = HashesEqual(before, ContentHashes(options.FixtureMarker));
|
var hashesEqual = HashesEqual(before, ContentHashes(options.FixtureMarker));
|
||||||
|
var apply = File.Exists(applyEvidence) ? JsonSerializer.Deserialize<ApplyEvidence>(await File.ReadAllTextAsync(applyEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) : null;
|
||||||
if (File.Exists(permitPath)) File.Delete(permitPath);
|
if (File.Exists(permitPath)) File.Delete(permitPath);
|
||||||
return started && hashesEqual ? Success : Fail(ProcessFailed, started ? "AUTOMATION_CONTENT_CHANGED" : "AUTOMATION_APPLY_SESSION_FAILED");
|
var applyVerified = apply is { Completed: true, CommandsCompleted: true, InspectorsCompleted: true, EvidenceFlushed: true, PermitAccepted: true, ApplyOutcomeRun1: "Created", ApplyOutcomeRun2: "AlreadyExists" } && apply.Baseline is not null && apply.AfterCommand1 is not null && apply.AfterCommand2 is not null && apply.AfterUndo is not null;
|
||||||
|
return applySession.Started && applySession.RollbackVerified && applySession.EvidenceCompleted && applyVerified && hashesEqual ? Success : Fail(ProcessFailed, !hashesEqual ? "AUTOMATION_CONTENT_CHANGED" : "AUTOMATION_APPLY_EVIDENCE_INVALID");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<bool> RunAutomationSession(PilotLocalConfiguration config, PackageManifest manifest, string manifestHash, AutomationOptions options, string phase)
|
private static async Task<AutomationSessionResult> RunAutomationSession(PilotLocalConfiguration config, PackageManifest manifest, string manifestHash, AutomationOptions options, string phase, string evidencePath)
|
||||||
{
|
{
|
||||||
var service = CreateService(); var product = new ProductInstallation(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe", "2026", "x64", []);
|
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 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 prepared = await service.PrepareAsync(request, CancellationToken.None); if (prepared.Plan is null) return new(false, false, false, false, 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;
|
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 new(false, false, false, false, false);
|
||||||
try { await WaitForExitAsync(execution.ProcessId.Value); }
|
var forcedCleanup = false;
|
||||||
|
var gracefulExit = false;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var exit = WaitForExitAsync(execution.ProcessId.Value);
|
||||||
|
if (!config.ForceAfterEvidence) { await exit; gracefulExit = true; }
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var timeout = Task.Delay(TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120)));
|
||||||
|
if (await Task.WhenAny(exit, timeout) == exit) gracefulExit = true;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!HasCompletedEvidence(evidencePath)) return new(true, false, false, false, false);
|
||||||
|
using var process = Process.GetProcessById(execution.ProcessId.Value);
|
||||||
|
if (!process.HasExited) { process.Kill(entireProcessTree: true); forcedCleanup = true; }
|
||||||
|
await exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
finally { await service.RollbackAsync(new(options.PilotRoot, manifest.PackageId, manifest.Version, execution.StartupScriptPath!, null, execution.SessionId, execution.ActivationSnapshotPath), CancellationToken.None); }
|
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);
|
var evidenceCompleted = HasCompletedEvidence(evidencePath);
|
||||||
|
return new(true, gracefulExit, forcedCleanup, await IsRolledBack(options.PilotRoot), evidenceCompleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryParseAutomation(string[] args, out AutomationOptions options, out string? error)
|
private static bool TryParseAutomation(string[] args, out AutomationOptions options, out string? error)
|
||||||
@@ -133,6 +157,11 @@ internal static class PilotCli
|
|||||||
private static bool HashesEqual(IReadOnlyDictionary<string,string> x, IReadOnlyDictionary<string,string> y) => x.Count == y.Count && x.All(p => y.TryGetValue(p.Key, out var value) && value == p.Value);
|
private static bool HashesEqual(IReadOnlyDictionary<string,string> x, IReadOnlyDictionary<string,string> 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 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 WaitForExitAsync(int processId) { using var process = Process.GetProcessById(processId); await process.WaitForExitAsync(); }
|
||||||
|
private static bool HasCompletedEvidence(string path)
|
||||||
|
{
|
||||||
|
try { using var json = JsonDocument.Parse(File.ReadAllText(path)); return json.RootElement.TryGetProperty("Completed", out var completed) && completed.ValueKind == JsonValueKind.True; }
|
||||||
|
catch { return false; }
|
||||||
|
}
|
||||||
private static async Task<int> RollbackAsync(string root)
|
private static async Task<int> RollbackAsync(string root)
|
||||||
{
|
{
|
||||||
var result = await CreateService().RollbackAsync(new(root, "P_Rec_Annot", null, string.Empty, null), CancellationToken.None);
|
var result = await CreateService().RollbackAsync(new(root, "P_Rec_Annot", null, string.Empty, null), CancellationToken.None);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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 $"_.NETLOAD\n\"{full.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 string BuildAutomationScript(string root, string entry) => BuildNetloadScript(root, entry) + "PHP_REC_AUTOMATION_START\n";
|
||||||
}
|
}
|
||||||
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
|
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public interface ILocalPackageStore { Task<string?> GetCurrentVersionAsync(strin
|
|||||||
public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
|
public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
|
||||||
public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); }
|
public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); }
|
||||||
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
|
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
|
||||||
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); }
|
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(string validatedPackageRoot, string entryAssembly); }
|
||||||
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
|
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
|
||||||
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
|
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
|
||||||
public interface IClock { DateTimeOffset UtcNow { get; } }
|
public interface IClock { DateTimeOffset UtcNow { get; } }
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ public sealed record PilotLocalConfiguration
|
|||||||
// dedicated test branch and still requires the plugin-side fixture permit.
|
// dedicated test branch and still requires the plugin-side fixture permit.
|
||||||
public bool AllowApply { get; init; }
|
public bool AllowApply { get; init; }
|
||||||
public string? ApplyPermitPath { get; init; }
|
public string? ApplyPermitPath { get; init; }
|
||||||
|
public string? AutomationMode { get; init; }
|
||||||
|
public string? AutomationConfigurationPath { get; init; }
|
||||||
|
// Test-only: permits a pilot process to be terminated after its plugin has
|
||||||
|
// persisted completed automation evidence. It never represents graceful exit.
|
||||||
|
public bool ForceAfterEvidence { get; init; }
|
||||||
|
public int GracefulExitTimeoutSeconds { get; init; } = 30;
|
||||||
public string? PilotRoot { get; init; }
|
public string? PilotRoot { get; init; }
|
||||||
public string? SelectedProductId { get; init; }
|
public string? SelectedProductId { get; init; }
|
||||||
public string? SelectedProfileName { get; init; }
|
public string? SelectedProfileName { get; init; }
|
||||||
@@ -61,7 +67,7 @@ public interface INextPilotSafetyGate { PilotSafetyDecision Evaluate(PilotSafety
|
|||||||
public sealed class NextPilotSafetyGate : INextPilotSafetyGate
|
public sealed class NextPilotSafetyGate : INextPilotSafetyGate
|
||||||
{
|
{
|
||||||
public PilotSafetyDecision Evaluate(PilotSafetyContext c, bool forExecution)
|
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); }
|
{ 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.Configuration.AutomationMode is not null && (!string.Equals(c.Configuration.AutomationMode, "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.AutomationConfigurationPath ?? string.Empty) || (c.Configuration.AutomationConfigurationPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("AUTOMATION_CONFIGURATION_INVALID"); if (c.Configuration.ForceAfterEvidence && c.Configuration.AutomationMode is null) v.Add("FORCED_CLEANUP_TEST_AUTOMATION_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 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 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);
|
||||||
|
|||||||
@@ -40,12 +40,19 @@ public sealed class PilotExecutionService(
|
|||||||
if (!safety.IsAllowed) return new(null, safety);
|
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 futureEntry = Path.Combine(request.PilotRoot, "packages", request.Manifest.PackageId, "versions", request.Manifest.Version, request.Manifest.EntryAssembly);
|
||||||
var content = $"_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\n";
|
var content = request.Configuration.AutomationMode is null
|
||||||
|
? $"_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\n"
|
||||||
|
: $"_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\nPHP_REC_AUTOMATION_START\n";
|
||||||
var scriptHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
|
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 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() };
|
var environment = new Dictionary<string, string> { ["P_REC_ANNOT_RUNTIME_MODE"] = request.Configuration.PluginExecutionMode.ToString() };
|
||||||
if (!string.IsNullOrWhiteSpace(request.Configuration.ApplyPermitPath))
|
if (!string.IsNullOrWhiteSpace(request.Configuration.ApplyPermitPath))
|
||||||
environment["P_REC_ANNOT_APPLY_PERMIT_PATH"] = request.Configuration.ApplyPermitPath;
|
environment["P_REC_ANNOT_APPLY_PERMIT_PATH"] = request.Configuration.ApplyPermitPath;
|
||||||
|
if (request.Configuration.AutomationMode is not null)
|
||||||
|
{
|
||||||
|
environment["P_REC_ANNOT_AUTOMATION"] = request.Configuration.AutomationMode;
|
||||||
|
environment["P_REC_ANNOT_AUTOMATION_CONFIG"] = request.Configuration.AutomationConfigurationPath!;
|
||||||
|
}
|
||||||
return new(plan with { Process = plan.Process with { EnvironmentVariables = environment } }, safety);
|
return new(plan with { Process = plan.Process with { EnvironmentVariables = environment } }, safety);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +102,10 @@ public sealed class PilotExecutionService(
|
|||||||
await WriteTextAtomic(previousPath, current ?? string.Empty, cancellationToken, current is not null);
|
await WriteTextAtomic(previousPath, current ?? string.Empty, cancellationToken, current is not null);
|
||||||
await WriteTextAtomic(currentPath, JsonSerializer.Serialize(new Pointer(plan.PackageVersion)), cancellationToken, true);
|
await WriteTextAtomic(currentPath, JsonSerializer.Serialize(new Pointer(plan.PackageVersion)), cancellationToken, true);
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
|
Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
|
||||||
await File.WriteAllTextAsync(scriptPath, scriptBuilder.BuildNetloadScript(versionDirectory, request.Preparation.Manifest.EntryAssembly), cancellationToken);
|
var script = request.Preparation.Configuration.AutomationMode is null
|
||||||
|
? scriptBuilder.BuildNetloadScript(versionDirectory, request.Preparation.Manifest.EntryAssembly)
|
||||||
|
: scriptBuilder.BuildAutomationScript(versionDirectory, request.Preparation.Manifest.EntryAssembly);
|
||||||
|
await File.WriteAllTextAsync(scriptPath, script, cancellationToken);
|
||||||
await WriteSession(plan.PilotRoot, (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "ProcessStarting", UpdatedAtUtc = DateTimeOffset.UtcNow }, 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);
|
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)
|
if (!result.Started)
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ public sealed class ControlledExecutionTests
|
|||||||
[Fact] public void PilotRoot_AllowedCorporateChild_IsAccepted() => Assert.True(new PilotRootPolicy("C:\\AutoCAD User Support").Validate("C:\\AutoCAD User Support\\LauncherNextPilot").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()
|
[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); }
|
{ 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 ForceAfterEvidence_RequiresTestAutomation()
|
||||||
|
{ var c=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=true,RequireExplicitExecute=true,PluginExecutionMode=PluginExecutionMode.DryRun,ForceAfterEvidence=true }; 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("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED",d.Violations); }
|
||||||
|
[Fact] public void ForceAfterEvidence_WithLocalAutomationConfiguration_IsAllowed()
|
||||||
|
{ var c=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=true,RequireExplicitExecute=true,PluginExecutionMode=PluginExecutionMode.DryRun,ForceAfterEvidence=true,AutomationMode="FirstLimitedApplyRuntimeTest",AutomationConfigurationPath="C:\\test\\automation.json" }; 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 Permit_IsOneTime()
|
[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)); }
|
{ 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()
|
[Fact] public void GuardedLauncher_RealExecutionReleaseDisabled_BlocksStart()
|
||||||
|
|||||||
Reference in New Issue
Block a user