Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
115f44fcb9 | ||
|
|
8a2ca2daa6 | ||
|
|
e2725c7f4c | ||
|
|
b88e09afd4 | ||
|
|
9209937b85 | ||
|
|
399cdf503e | ||
|
|
72cd0cc337 | ||
|
|
dc9aeff1b5 |
@@ -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,8 +75,104 @@ 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, 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 AutomationSessionResult(bool Started, bool GracefulExitVerified, bool ForcedCleanupUsed, bool RollbackVerified, bool EvidenceCompleted);
|
||||
|
||||
private static async Task<int> 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 || !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 (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 }));
|
||||
await File.WriteAllTextAsync(previewEvidence, "{\"completed\":false}");
|
||||
var previewConfig = baseConfig with { PluginExecutionMode = PluginExecutionMode.DryRun, AllowApply = false, ApplyPermitPath = null, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = previewConfigPath };
|
||||
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 });
|
||||
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");
|
||||
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 }));
|
||||
await File.WriteAllTextAsync(applyEvidence, "{\"completed\":false}");
|
||||
var applyConfig = baseConfig with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath };
|
||||
var applySession = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply", applyEvidence);
|
||||
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);
|
||||
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<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 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 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 new(false, false, false, false, false);
|
||||
var forcedCleanup = false;
|
||||
var gracefulExit = false;
|
||||
try
|
||||
{
|
||||
var exit = WaitForExitAsync(execution.ProcessId.Value);
|
||||
if (!config.ForceAfterEvidence) { await exit; gracefulExit = true; }
|
||||
else
|
||||
{
|
||||
// The graceful-exit timeout is deliberately measured only after
|
||||
// terminal evidence is durable. Killing an active AutoCAD
|
||||
// command before that boundary would turn ForceAfterEvidence
|
||||
// into an unsafe general process killer.
|
||||
while (!exit.IsCompleted && !HasCompletedEvidence(evidencePath))
|
||||
await Task.Delay(TimeSpan.FromSeconds(1));
|
||||
if (exit.IsCompleted) 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
|
||||
{
|
||||
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); }
|
||||
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)
|
||||
{
|
||||
options = null!; error = null; var d = new Dictionary<string, string>(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<string,string> 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<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 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)
|
||||
{
|
||||
var result = await CreateService().RollbackAsync(new(root, "P_Rec_Annot", null, string.Empty, null), CancellationToken.None);
|
||||
@@ -129,8 +226,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;
|
||||
|
||||
@@ -16,6 +16,14 @@ public sealed class StartupScriptBuilder : IStartupScriptBuilder
|
||||
{
|
||||
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"; }
|
||||
public string BuildAutomationScript(string root, string entry, string fixtureOrthoDwg)
|
||||
{
|
||||
var fixture = Path.GetFullPath(fixtureOrthoDwg);
|
||||
if (!File.Exists(fixture)) throw new InvalidOperationException("AUTOMATION_FIXTURE_NOT_FOUND");
|
||||
var quote = (string value) => "\"" + value.Replace("\"", "\\\"") + "\"";
|
||||
return "_.OPEN\n" + quote(fixture) + "\n" + BuildNetloadScript(root, entry) +
|
||||
"PHP_REC_AUTOMATION_PREPARE\nPXP-OA\nPXP-OA\nPHP_REC_AUTOMATION_FINALIZE\n";
|
||||
}
|
||||
}
|
||||
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
namespace Launcher.Next.Core;
|
||||
|
||||
public enum LauncherMode { Legacy, DryRun, NextPilot, NextPreferred }
|
||||
public enum PluginExecutionMode { Smoke, DryRun }
|
||||
public enum PluginExecutionMode { Smoke, DryRun, Apply }
|
||||
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 StartupDefinition(string Type, string Command);
|
||||
@@ -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 IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); }
|
||||
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, string fixtureOrthoDwg); }
|
||||
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
|
||||
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
|
||||
public interface IClock { DateTimeOffset UtcNow { get; } }
|
||||
|
||||
@@ -13,6 +13,16 @@ public sealed record PilotLocalConfiguration
|
||||
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? 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? SelectedProductId { get; init; }
|
||||
public string? SelectedProfileName { get; init; }
|
||||
@@ -57,7 +67,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.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); }
|
||||
{ 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 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,10 +40,22 @@ public sealed class PilotExecutionService(
|
||||
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 fixture = request.Configuration.AutomationMode is null ? null : ReadFixtureOrtho(request.Configuration.AutomationConfigurationPath);
|
||||
var content = request.Configuration.AutomationMode is null
|
||||
? $"_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\n"
|
||||
: $"_.OPEN\n\"{fixture!.Replace("\"", "\\\"")}\"\n_.NETLOAD\n\"{futureEntry.Replace("\"", "\\\"")}\"\nPHP_REC_AUTOMATION_PREPARE\nPXP-OA\nPXP-OA\nPHP_REC_AUTOMATION_FINALIZE\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);
|
||||
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;
|
||||
if (request.Configuration.AutomationMode is not null)
|
||||
{
|
||||
environment["P_REC_ANNOT_LAUNCHER_MODE"] = "NextPilot";
|
||||
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);
|
||||
}
|
||||
|
||||
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken)
|
||||
@@ -92,7 +104,10 @@ public sealed class PilotExecutionService(
|
||||
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);
|
||||
var script = request.Preparation.Configuration.AutomationMode is null
|
||||
? scriptBuilder.BuildNetloadScript(versionDirectory, request.Preparation.Manifest.EntryAssembly)
|
||||
: scriptBuilder.BuildAutomationScript(versionDirectory, request.Preparation.Manifest.EntryAssembly, ReadFixtureOrtho(request.Preparation.Configuration.AutomationConfigurationPath));
|
||||
await File.WriteAllTextAsync(scriptPath, script, 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)
|
||||
@@ -111,6 +126,14 @@ public sealed class PilotExecutionService(
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadFixtureOrtho(string? automationConfigurationPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(automationConfigurationPath) || !File.Exists(automationConfigurationPath)) throw new InvalidOperationException("AUTOMATION_CONFIG_MISSING");
|
||||
using var json = JsonDocument.Parse(File.ReadAllText(automationConfigurationPath));
|
||||
if (!json.RootElement.TryGetProperty("orthoDrawingPath", out var value) || string.IsNullOrWhiteSpace(value.GetString())) throw new InvalidOperationException("AUTOMATION_ORTHO_PATH_REQUIRED");
|
||||
return Path.GetFullPath(value.GetString()!);
|
||||
}
|
||||
|
||||
public async Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -4,12 +4,22 @@ 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 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()
|
||||
{ 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()
|
||||
|
||||
@@ -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<object>()); }
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user