refactor: resolve launcher paths and isolate batch automation

This commit is contained in:
codex
2026-08-04 11:48:30 +03:00
parent ea6f3cdcdf
commit e13f44ac3c
11 changed files with 404 additions and 44 deletions
+42 -12
View File
@@ -28,14 +28,15 @@ internal static class PilotCli
if (command == "status") return await StatusAsync(root); if (command == "status") return await StatusAsync(root);
if (command == "rollback") return await RollbackAsync(root); if (command == "rollback") return await RollbackAsync(root);
if (!TryReadConfiguration(root, out var config, out error)) return Fail(Blocked, error!); 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 (!TryResolvePaths(config, out var paths, out error)) return Fail(Blocked, error!);
if (!string.Equals(paths.LocalLauncherRoot, root, StringComparison.OrdinalIgnoreCase) || !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!); if (!TryBuildManifest(options.PackageSource!, options.EntryAssembly!, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!);
Directory.CreateDirectory(Path.Combine(root, "catalog")); Directory.CreateDirectory(Path.Combine(root, "catalog"));
await File.WriteAllTextAsync(Path.Combine(root, "catalog", "P_Rec_Annot.manifest.json"), JsonSerializer.Serialize(manifest)); 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 (!File.Exists(paths.AcadExecutable)) return Fail(DiscoveryFailed, "PLANT3D_2026_NOT_FOUND");
if (command == "run" && File.Exists(SessionPath(root)) && await HasActiveSession(root)) return await HandleExistingSession(root); if (command == "run" && File.Exists(SessionPath(root)) && await HasActiveSession(root)) return await HandleExistingSession(root);
var service = CreateService(); var service = CreateService(paths);
var product = new ProductInstallation(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe", "2026", "x64", []); var product = new ProductInstallation(paths.AcadExecutable, "2026", "x64", []);
var request = new PilotPreparationRequest(config, product, manifest!, manifestHash!, options.PackageSource!, root, Path.Combine(root, "startup", "prepare-only.scr")); 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); 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)); if (prepared.Plan is null) return Fail(prepared.SafetyDecision.Violations.Contains("ACAD_ALREADY_RUNNING") ? AcadRunning : Blocked, string.Join(',', prepared.SafetyDecision.Violations));
@@ -86,6 +87,7 @@ internal static class PilotCli
{ {
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 (!TryResolvePaths(baseConfig, out var basePaths, 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 (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");
@@ -95,7 +97,7 @@ 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 }));
await File.WriteAllTextAsync(previewEvidence, "{\"completed\":false}"); await File.WriteAllTextAsync(previewEvidence, "{\"completed\":false}");
var previewConfig = baseConfig with { PluginExecutionMode = PluginExecutionMode.DryRun, AllowApply = false, ApplyPermitPath = null, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = previewConfigPath }; var previewConfig = WithAutomationPaths(baseConfig, options, previewConfigPath, previewEvidence, null, "preview") with { PluginExecutionMode = PluginExecutionMode.DryRun, AllowApply = false, ApplyPermitPath = null, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = previewConfigPath, FixtureMarkerPath = options.FixtureMarker, LaunchMode = AcadLaunchMode.NextPilotPreview };
var previewSession = await RunAutomationSession(previewConfig, manifest!, manifestHash!, options, "preview", previewEvidence); var previewSession = await RunAutomationSession(previewConfig, manifest!, manifestHash!, options, "preview", previewEvidence);
if (!previewSession.Started || !previewSession.RollbackVerified || !previewSession.EvidenceCompleted) return Fail(ProcessFailed, "AUTOMATION_PREVIEW_SESSION_FAILED"); 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 });
@@ -107,7 +109,7 @@ 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 }));
await File.WriteAllTextAsync(applyEvidence, "{\"completed\":false}"); await File.WriteAllTextAsync(applyEvidence, "{\"completed\":false}");
var applyConfig = baseConfig with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath }; var applyConfig = WithAutomationPaths(baseConfig, options, applyConfigPath, applyEvidence, permitPath, "apply") with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath, FixtureMarkerPath = options.FixtureMarker, LaunchMode = AcadLaunchMode.NextPilotApply };
var applySession = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply", applyEvidence); 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; var apply = File.Exists(applyEvidence) ? JsonSerializer.Deserialize<ApplyEvidence>(await File.ReadAllTextAsync(applyEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) : null;
@@ -118,7 +120,8 @@ internal static class PilotCli
private static async Task<AutomationSessionResult> RunAutomationSession(PilotLocalConfiguration config, PackageManifest manifest, string manifestHash, AutomationOptions options, string phase, string evidencePath) 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", []); if (!TryResolvePaths(config, out var paths, out _)) return new(false, false, false, false, false);
var service = CreateService(paths); var product = new ProductInstallation(paths.AcadExecutable, "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 new(false, false, false, false, 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 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);
@@ -166,7 +169,7 @@ internal static class PilotCli
} }
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 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 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(ResolvedLauncherPaths paths) => new(new PackageManifestValidator(), new PilotRootPolicy(paths.LocalLauncherRoot), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker(), new PathResolver());
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) private static bool HasCompletedEvidence(string path)
{ {
@@ -175,7 +178,8 @@ internal static class PilotCli
} }
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); if (!TryReadConfiguration(root, out var config, out _) || !TryResolvePaths(config, out var paths, out _)) return Fail(ExplicitRollbackFailed, "PATH_CONFIGURATION_INVALID");
var result = await CreateService(paths).RollbackAsync(new(root, "P_Rec_Annot", null, string.Empty, null), CancellationToken.None);
return result.Completed ? Success : Fail(ExplicitRollbackFailed, result.ErrorCode ?? "ROLLBACK_FAILED"); return result.Completed ? Success : Fail(ExplicitRollbackFailed, result.ErrorCode ?? "ROLLBACK_FAILED");
} }
private static async Task<int> StatusAsync(string root) private static async Task<int> StatusAsync(string root)
@@ -207,12 +211,38 @@ internal static class PilotCli
private static string SessionPath(string root) => Path.Combine(root, "state", "pilot-session.json"); 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) 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"); config = PilotLocalConfiguration.SafeDefault; error = null; var directory = Path.Combine(root, "config"); var defaults = Path.Combine(directory, "launcher.defaults.json"); var machine = Path.Combine(directory, "launcher.machine.json"); var session = Path.Combine(directory, "launcher.session.json");
if (!File.Exists(path)) { error = "PILOT_CONFIGURATION_MISSING"; return false; } if (!File.Exists(machine)) { error = "PILOT_CONFIGURATION_MISSING"; return false; }
var parsed = PilotConfigurationParser.Parse(File.ReadAllText(path)); config = parsed.Configuration; var parsed = LauncherConfigurationLayers.LoadFromFiles(defaults, machine, session); config = parsed.Configuration;
if (!parsed.IsValid) { error = "PILOT_CONFIGURATION_INVALID"; return false; } if (!parsed.IsValid) { error = "PILOT_CONFIGURATION_INVALID"; return false; }
return true; return true;
} }
private static bool TryResolvePaths(PilotLocalConfiguration configuration, out ResolvedLauncherPaths paths, out string? error)
{
try
{
var resolver = new PathResolver(); var variables = new LauncherPathVariables(configuration.PathVariables); var raw = resolver.Resolve(variables, [LauncherPathVariableNames.LocalLauncherRoot, LauncherPathVariableNames.AcadExecutable]);
var resolved = new Dictionary<string, string?>(raw.Values, StringComparer.OrdinalIgnoreCase);
foreach (var name in new[] { LauncherPathVariableNames.LocalLauncherRoot, LauncherPathVariableNames.LocalPackageCache, LauncherPathVariableNames.LocalActiveRoot, LauncherPathVariableNames.LocalSessionRoot, LauncherPathVariableNames.LocalEvidenceRoot, LauncherPathVariableNames.LocalLogsRoot, LauncherPathVariableNames.LocalRollbackRoot, LauncherPathVariableNames.AcadExecutable, LauncherPathVariableNames.PRecEntryAssembly, LauncherPathVariableNames.FixtureOrthoDwg, LauncherPathVariableNames.AutomationScriptPath, LauncherPathVariableNames.AutomationConfigPath, LauncherPathVariableNames.AutomationEvidencePath, LauncherPathVariableNames.AutomationPermitPath })
if (raw.Get(name) is { Length: > 0 } value) resolved[name] = resolver.ResolvePath(value, variables);
paths = new(resolved); error = null; return true;
}
catch (Exception ex) { paths = null!; error = ex.Message; return false; }
}
private static PilotLocalConfiguration WithAutomationPaths(PilotLocalConfiguration config, AutomationOptions options, string configPath, string evidencePath, string? permitPath, string phase)
{
var variables = new Dictionary<string, string?>(config.PathVariables, StringComparer.OrdinalIgnoreCase)
{
[LauncherPathVariableNames.LocalSessionRoot] = options.EvidenceRoot,
[LauncherPathVariableNames.AutomationScriptPath] = Path.Combine(options.EvidenceRoot, phase + ".scr"),
[LauncherPathVariableNames.AutomationConfigPath] = configPath,
[LauncherPathVariableNames.AutomationEvidencePath] = evidencePath,
[LauncherPathVariableNames.AutomationPermitPath] = permitPath,
[LauncherPathVariableNames.FixtureOrthoDwg] = options.OrthoDrawing,
[LauncherPathVariableNames.PRecEntryAssembly] = options.EntryAssembly
};
return config with { PathVariables = variables };
}
private static bool TryBuildManifest(string source, string entry, out PackageManifest? manifest, out string? hash, out string? error) private static bool TryBuildManifest(string source, string entry, out PackageManifest? manifest, out string? hash, out string? error)
{ {
manifest = null; hash = null; error = null; manifest = null; hash = null; error = null;
+3 -3
View File
@@ -16,12 +16,12 @@ 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, string fixtureOrthoDwg) public string BuildAutomationScript(ResolvedAutomationScriptPaths paths)
{ {
var fixture = Path.GetFullPath(fixtureOrthoDwg); var fixture = Path.GetFullPath(paths.FixtureOrthoDwg);
if (!File.Exists(fixture)) throw new InvalidOperationException("AUTOMATION_FIXTURE_NOT_FOUND"); if (!File.Exists(fixture)) throw new InvalidOperationException("AUTOMATION_FIXTURE_NOT_FOUND");
var quote = (string value) => "\"" + value.Replace("\"", "\\\"") + "\""; var quote = (string value) => "\"" + value.Replace("\"", "\\\"") + "\"";
return "_.OPEN\n" + quote(fixture) + "\n" + BuildNetloadScript(root, entry) + return "_.OPEN\n" + quote(fixture) + "\n" + BuildNetloadScript(paths.ValidatedPackageRoot, paths.EntryAssembly) +
"PHP_REC_AUTOMATION_PREPARE\nPXP-OA\nPXP-OA\nPHP_REC_AUTOMATION_FINALIZE\n"; "PHP_REC_AUTOMATION_PREPARE\nPXP-OA\nPXP-OA\nPHP_REC_AUTOMATION_FINALIZE\n";
} }
} }
+2 -1
View File
@@ -28,7 +28,8 @@ 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); string BuildAutomationScript(string validatedPackageRoot, string entryAssembly, string fixtureOrthoDwg); } public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string AutomationScriptPath);
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(ResolvedAutomationScriptPaths paths); }
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; } }
+158
View File
@@ -0,0 +1,158 @@
using System.Text.RegularExpressions;
using System.Text.Json;
using System.Text.Json.Nodes;
namespace Launcher.Next.Core;
public sealed record LauncherPathVariables(IReadOnlyDictionary<string, string?> Values)
{
public string? this[string name] => Values.TryGetValue(name, out var value) ? value : null;
}
public sealed record ResolvedLauncherPaths(IReadOnlyDictionary<string, string?> Values)
{
public string Require(string name) => Values.TryGetValue(name, out var value) && !string.IsNullOrWhiteSpace(value)
? value : throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}");
public string? Get(string name) => Values.TryGetValue(name, out var value) ? value : null;
public string LocalLauncherRoot => Require(nameof(LocalLauncherRoot));
public string AcadExecutable => Require(nameof(AcadExecutable));
public string? AutomationScriptPath => Get(nameof(AutomationScriptPath));
public string? FixtureOrthoDwg => Get(nameof(FixtureOrthoDwg));
}
public interface IPathResolver
{
ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable<string>? required = null);
string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null);
}
public sealed class PathResolver(IReadOnlyDictionary<string, string?>? environment = null) : IPathResolver
{
private static readonly Regex Token = new(@"\$\{(?<name>[A-Za-z][A-Za-z0-9_]*)\}", RegexOptions.Compiled);
private readonly IReadOnlyDictionary<string, string?> environment = environment ?? Environment.GetEnvironmentVariables()
.Cast<System.Collections.DictionaryEntry>().ToDictionary(x => (string)x.Key, x => x.Value?.ToString(), StringComparer.OrdinalIgnoreCase);
public ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable<string>? required = null)
{
var resolved = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
foreach (var name in variables.Values.Keys) resolved[name] = ResolveVariable(name, variables, resolved, []);
foreach (var name in required ?? [])
if (!resolved.TryGetValue(name, out var value) || string.IsNullOrWhiteSpace(value))
throw new InvalidOperationException($"PATH_VARIABLE_REQUIRED:{name}");
return new(resolved);
}
public string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null)
{
var resolved = Expand(value, variables, new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase), []);
if (string.IsNullOrWhiteSpace(resolved) || !Path.IsPathFullyQualified(resolved))
throw new InvalidOperationException("PATH_ABSOLUTE_REQUIRED");
var full = Path.GetFullPath(resolved);
if (full.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase))
{
var corporate = Resolve(variables, ["CorporateDfsRoot"]).Require("CorporateDfsRoot");
full = Path.Combine(corporate, full[3..]);
}
if (full.StartsWith("A:\\", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_DRIVE_A_UNRESOLVED");
if (!string.IsNullOrWhiteSpace(constrainedRoot))
{
var root = Path.GetFullPath(ResolvePath(constrainedRoot, variables));
if (!full.Equals(root, StringComparison.OrdinalIgnoreCase) && !full.StartsWith(root.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException("PATH_OUTSIDE_CONSTRAINED_ROOT");
}
return full;
}
private string? ResolveVariable(string name, LauncherPathVariables variables, Dictionary<string, string?> resolved, HashSet<string> stack)
{
if (resolved.TryGetValue(name, out var cached)) return cached;
if (!stack.Add(name)) throw new InvalidOperationException($"PATH_VARIABLE_CYCLE:{name}");
try
{
var source = variables[name] ?? (environment.TryGetValue(name, out var fromEnvironment) ? fromEnvironment : null);
if (source is null) throw new InvalidOperationException($"PATH_VARIABLE_UNKNOWN:{name}");
var expanded = Token.Replace(source, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}"));
expanded = expanded.Replace('/', Path.DirectorySeparatorChar);
resolved[name] = expanded;
return expanded;
}
finally { stack.Remove(name); }
}
private string Expand(string value, LauncherPathVariables variables, Dictionary<string, string?> resolved, HashSet<string> stack) =>
Token.Replace(value, match => ResolveVariable(match.Groups["name"].Value, variables, resolved, stack) ?? throw new InvalidOperationException($"PATH_VARIABLE_EMPTY:{match.Groups["name"].Value}"));
}
public static class LauncherPathVariableNames
{
public const string CorporateDfsRoot = "CorporateDfsRoot";
public const string CorporateLauncherRoot = "CorporateLauncherRoot";
public const string CorporateCatalogRoot = "CorporateCatalogRoot";
public const string CorporatePackagesRoot = "CorporatePackagesRoot";
public const string CorporateProfilesRoot = "CorporateProfilesRoot";
public const string CorporatePalettesRoot = "CorporatePalettesRoot";
public const string CorporateFontsRoot = "CorporateFontsRoot";
public const string CorporateTemplatesRoot = "CorporateTemplatesRoot";
public const string CorporateScriptsRoot = "CorporateScriptsRoot";
public const string ProjectName = "ProjectName";
public const string DisciplineName = "DisciplineName";
public const string ProjectRoot = "ProjectRoot";
public const string DisciplineRoot = "DisciplineRoot";
public const string PlantProjectFile = "PlantProjectFile";
public const string ProjectRosterPath = "ProjectRosterPath";
public const string ProjectApplicationsRoot = "ProjectApplicationsRoot";
public const string ProjectPaletteOverlayRoot = "ProjectPaletteOverlayRoot";
public const string LocalLauncherRoot = "LocalLauncherRoot";
public const string LocalPackageCache = "LocalPackageCache";
public const string LocalActiveRoot = "LocalActiveRoot";
public const string LocalSessionRoot = "LocalSessionRoot";
public const string LocalEvidenceRoot = "LocalEvidenceRoot";
public const string LocalLogsRoot = "LocalLogsRoot";
public const string LocalRollbackRoot = "LocalRollbackRoot";
public const string AcadExecutable = "AcadExecutable";
public const string PRecEntryAssembly = "PRecEntryAssembly";
public const string FixtureOrthoDwg = "FixtureOrthoDwg";
public const string AutomationScriptPath = "AutomationScriptPath";
public const string AutomationConfigPath = "AutomationConfigPath";
public const string AutomationEvidencePath = "AutomationEvidencePath";
public const string AutomationPermitPath = "AutomationPermitPath";
}
public static class LauncherConfigurationLayers
{
private static readonly HashSet<string> Protected = [LauncherPathVariableNames.CorporateDfsRoot, LauncherPathVariableNames.AcadExecutable, LauncherPathVariableNames.LocalLauncherRoot];
public static PilotConfigurationLoadResult Load(string? corporateDefaultsJson, string? machineJson, string? sessionJson)
{
try
{
var merged = Parse(corporateDefaultsJson);
Merge(merged, Parse(machineJson), false);
Merge(merged, Parse(sessionJson), false);
return PilotConfigurationParser.Parse(merged.ToJsonString());
}
catch (JsonException) { return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]); }
}
public static PilotConfigurationLoadResult LoadFromFiles(string corporateDefaultsPath, string machinePath, string sessionPath)
{
var defaults = File.Exists(corporateDefaultsPath) ? File.ReadAllText(corporateDefaultsPath) : null;
var machine = File.Exists(machinePath) ? File.ReadAllText(machinePath) : null;
var session = File.Exists(sessionPath) ? File.ReadAllText(sessionPath) : null;
return Load(defaults, machine, session);
}
private static JsonObject Parse(string? json) => string.IsNullOrWhiteSpace(json) ? [] : JsonNode.Parse(json)?.AsObject() ?? [];
private static void Merge(JsonObject target, JsonObject overlay, bool roster)
{
foreach (var entry in overlay)
{
if (entry.Key.Equals("PathVariables", StringComparison.OrdinalIgnoreCase) && entry.Value is JsonObject paths)
{
var targetPaths = target["PathVariables"] as JsonObject ?? new JsonObject(); target["PathVariables"] = targetPaths;
foreach (var path in paths) if (!roster || !Protected.Contains(path.Key)) targetPaths[path.Key] = path.Value?.DeepClone();
}
else target[entry.Key] = entry.Value?.DeepClone();
}
}
}
+36 -3
View File
@@ -19,6 +19,7 @@ public sealed record PilotLocalConfiguration
public string? ApplyPermitPath { get; init; } public string? ApplyPermitPath { get; init; }
public string? AutomationMode { get; init; } public string? AutomationMode { get; init; }
public string? AutomationConfigurationPath { get; init; } public string? AutomationConfigurationPath { get; init; }
public string? FixtureMarkerPath { get; init; }
// Test-only: permits a pilot process to be terminated after its plugin has // Test-only: permits a pilot process to be terminated after its plugin has
// persisted completed automation evidence. It never represents graceful exit. // persisted completed automation evidence. It never represents graceful exit.
public bool ForceAfterEvidence { get; init; } public bool ForceAfterEvidence { get; init; }
@@ -27,8 +28,12 @@ public sealed record PilotLocalConfiguration
public string? SelectedProductId { get; init; } public string? SelectedProductId { get; init; }
public string? SelectedProfileName { get; init; } public string? SelectedProfileName { get; init; }
public int ExecutionPermitLifetimeSeconds { get; init; } = 300; public int ExecutionPermitLifetimeSeconds { get; init; } = 300;
public AcadLaunchMode LaunchMode { get; init; } = AcadLaunchMode.Production;
public bool ProductionDeploy { get; init; }
public IReadOnlyDictionary<string, string?> PathVariables { get; init; } = new Dictionary<string, string?>();
public static PilotLocalConfiguration SafeDefault { get; } = new(); public static PilotLocalConfiguration SafeDefault { get; } = new();
} }
public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe }
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors) public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
{ public bool IsValid => Errors.Count == 0; } { public bool IsValid => Errors.Count == 0; }
public static class PilotConfigurationParser public static class PilotConfigurationParser
@@ -67,14 +72,42 @@ 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.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); } {
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");
var automation = c.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
if (automation && (!string.Equals(c.Configuration.AutomationMode, "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.AutomationConfigurationPath ?? string.Empty) || (c.Configuration.AutomationConfigurationPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.FixtureMarkerPath ?? string.Empty) || (c.Configuration.FixtureMarkerPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || c.Configuration.ProductionDeploy)) v.Add("AUTOMATION_CONFIGURATION_INVALID");
if (!automation && c.Configuration.AutomationMode is not null) v.Add("AUTOMATION_MODE_MISMATCH");
if (c.Configuration.ForceAfterEvidence && !automation) 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) 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);
public sealed class AcadArgumentBuilder
{
public IReadOnlyList<string> Build(AcadLaunchMode mode, string? automationScriptPath)
{
var arguments = new List<string> { "/product", "PLNT3D", "/language", "ru-RU" };
if (mode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe)
{
if (string.IsNullOrWhiteSpace(automationScriptPath) || !Path.IsPathFullyQualified(automationScriptPath)) throw new InvalidOperationException("AUTOMATION_SCRIPT_PATH_REQUIRED");
arguments.Add("/b"); arguments.Add(automationScriptPath);
}
else if (!string.IsNullOrWhiteSpace(automationScriptPath)) throw new InvalidOperationException("PRODUCTION_BATCH_SCRIPT_FORBIDDEN");
return arguments;
}
}
public static class PilotPlanFactory public static class PilotPlanFactory
{ {
public static PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision decision) public static PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision decision, AcadLaunchMode mode = AcadLaunchMode.Production)
{ var version = Path.Combine(root, "packages", manifest.PackageId, "versions", manifest.Version); var entry = Path.Combine(version, manifest.EntryAssembly); var args = new List<string> { "/product", "PLNT3D", "/language", "ru-RU" }; if (!string.IsNullOrWhiteSpace(profile.Name)) { args.Add("/p"); args.Add(profile.Name); } args.Add("/b"); args.Add(scriptPath); var process = new ProcessLaunchSpecification(product.ExecutablePath, args, Path.GetDirectoryName(product.ExecutablePath)!, new Dictionary<string,string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }); var material = string.Join('|', manifest.PackageId, manifest.Version, manifestHash, product.ExecutablePath, profile.Name, entry, scriptHash); var id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))); return new(id, product, profile, manifest.PackageId, manifest.Version, manifestHash, root, version, entry, scriptPath, scriptHash, process, decision); } { var version = Path.Combine(root, "packages", manifest.PackageId, "versions", manifest.Version); var entry = Path.Combine(version, manifest.EntryAssembly); var args = new AcadArgumentBuilder().Build(mode, mode == AcadLaunchMode.Production ? null : scriptPath); var process = new ProcessLaunchSpecification(product.ExecutablePath, args, Path.GetDirectoryName(product.ExecutablePath)!, new Dictionary<string,string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }); var material = string.Join('|', manifest.PackageId, manifest.Version, manifestHash, product.ExecutablePath, entry, scriptHash, mode); var id = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(material))); return new(id, product, profile, manifest.PackageId, manifest.Version, manifestHash, root, version, entry, scriptPath, scriptHash, process, decision); }
} }
public sealed record ExecutionPermit(string PlanId, string Fingerprint, DateTimeOffset ExpiresAtUtc, Guid Id); public sealed record ExecutionPermit(string PlanId, string Fingerprint, DateTimeOffset ExpiresAtUtc, Guid Id);
public interface IExecutionPermitService { ExecutionPermit Issue(PilotLaunchPlan plan, int lifetimeSeconds); bool Validate(ExecutionPermit permit, PilotLaunchPlan plan, DateTimeOffset now); } public interface IExecutionPermitService { ExecutionPermit Issue(PilotLaunchPlan plan, int lifetimeSeconds); bool Validate(ExecutionPermit permit, PilotLaunchPlan plan, DateTimeOffset now); }
@@ -13,7 +13,8 @@ public sealed class PilotExecutionService(
IStartupScriptBuilder scriptBuilder, IStartupScriptBuilder scriptBuilder,
IExecutionPermitService permits, IExecutionPermitService permits,
IAutoCadProcessProbe probe, IAutoCadProcessProbe probe,
IOperatingSystemProcessInvoker invoker) : IPilotExecutionService IOperatingSystemProcessInvoker invoker,
IPathResolver? pathResolver = null) : IPilotExecutionService
{ {
private sealed record Pointer(string Version); private sealed record Pointer(string Version);
private sealed record ActivationSnapshot( private sealed record ActivationSnapshot(
@@ -25,6 +26,7 @@ public sealed class PilotExecutionService(
int SchemaVersion, string SessionId, string TransactionId, string State, int SchemaVersion, string SessionId, string TransactionId, string State,
string PackageId, string PackageVersion, string ManifestSha256, string PackageId, string PackageVersion, string ManifestSha256,
string PilotRoot, string VersionDirectory, string StartupScriptPath, string PilotRoot, string VersionDirectory, string StartupScriptPath,
string LaunchMode, IReadOnlyDictionary<string, string?> ResolvedPaths, IReadOnlyList<string> AcadArguments, string? ActualStartupScriptSha256,
string ActivationSnapshotPath, int? ProcessId, DateTimeOffset? ProcessStartUtc, string ActivationSnapshotPath, int? ProcessId, DateTimeOffset? ProcessStartUtc,
int? ProcessExitCode, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc, int? ProcessExitCode, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc,
string? LastErrorCode, string? LastErrorMessage); string? LastErrorCode, string? LastErrorMessage);
@@ -32,28 +34,30 @@ public sealed class PilotExecutionService(
public async Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken) public async Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
var root = rootPolicy.Validate(request.PilotRoot); var resolver = pathResolver ?? new PathResolver();
var paths = resolver.Resolve(new LauncherPathVariables(request.Configuration.PathVariables), [LauncherPathVariableNames.LocalLauncherRoot, LauncherPathVariableNames.AcadExecutable]);
if (!string.Equals(Path.GetFullPath(request.PilotRoot), paths.LocalLauncherRoot, StringComparison.OrdinalIgnoreCase))
return new(null, new(false, ["PILOT_ROOT_PATH_VARIABLE_MISMATCH"]));
var root = rootPolicy.Validate(paths.LocalLauncherRoot);
var validation = validator.Validate(request.Manifest, 2026, "x64"); var validation = validator.Validate(request.Manifest, 2026, "x64");
var hashes = await ValidateSource(request.Manifest, request.PackageSourceRoot, cancellationToken); var hashes = await ValidateSource(request.Manifest, request.PackageSourceRoot, cancellationToken);
var product = request.Product with { ExecutablePath = paths.AcadExecutable };
var profile = new AutoCadProfileCandidate(string.Empty, false, true, ["Confirmed Plant 3D launch contract has no /p argument."]); 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); var safety = gate.Evaluate(new(request.Configuration, product, profile, root, validation, hashes, false, probe.IsAcadRunning()), false);
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 automation = request.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
var fixture = request.Configuration.AutomationMode is null ? null : ReadFixtureOrtho(request.Configuration.AutomationConfigurationPath); var content = automation ? "AUTOMATION_SCRIPT_PENDING" : "PRODUCTION_NO_SCRIPT";
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 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(product, profile, request.Manifest, request.ManifestHash, paths.LocalLauncherRoot, automation ? paths.Require(LauncherPathVariableNames.AutomationScriptPath) : string.Empty, scriptHash, safety, request.Configuration.LaunchMode);
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) if (automation)
{ {
environment["P_REC_ANNOT_LAUNCHER_MODE"] = "NextPilot"; environment["P_REC_ANNOT_LAUNCHER_MODE"] = "NextPilot";
environment["P_REC_ANNOT_AUTOMATION"] = request.Configuration.AutomationMode; environment["P_REC_ANNOT_AUTOMATION"] = request.Configuration.AutomationMode!;
environment["P_REC_ANNOT_AUTOMATION_CONFIG"] = request.Configuration.AutomationConfigurationPath!; environment["P_REC_ANNOT_AUTOMATION_CONFIG"] = paths.Require(LauncherPathVariableNames.AutomationConfigPath);
} }
return new(plan with { Process = plan.Process with { EnvironmentVariables = environment } }, safety); return new(plan with { Process = plan.Process with { EnvironmentVariables = environment } }, safety);
} }
@@ -61,6 +65,8 @@ public sealed class PilotExecutionService(
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken) public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken)
{ {
var plan = request.Plan; var plan = request.Plan;
var resolver = pathResolver ?? new PathResolver();
var resolvedPaths = resolver.Resolve(new LauncherPathVariables(request.Preparation.Configuration.PathVariables));
var root = rootPolicy.Validate(request.Preparation.PilotRoot); var root = rootPolicy.Validate(request.Preparation.PilotRoot);
var validation = validator.Validate(request.Preparation.Manifest, 2026, "x64"); var validation = validator.Validate(request.Preparation.Manifest, 2026, "x64");
var hashes = await ValidateSource(request.Preparation.Manifest, request.Preparation.PackageSourceRoot, cancellationToken); var hashes = await ValidateSource(request.Preparation.Manifest, request.Preparation.PackageSourceRoot, cancellationToken);
@@ -68,7 +74,7 @@ public sealed class PilotExecutionService(
new AutoCadProfileCandidate(string.Empty, false, true, ["No profile in confirmed contract."]), root, validation, hashes, false, probe.IsAcadRunning()); new AutoCadProfileCandidate(string.Empty, false, true, ["No profile in confirmed contract."]), root, validation, hashes, false, probe.IsAcadRunning());
var decision = gate.Evaluate(context, true); var decision = gate.Evaluate(context, true);
if (!decision.IsAllowed) return new(false, null, decision.Violations.First(), null, null); 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)) if (!string.Equals(plan.PlanId, PilotPlanFactory.Create(plan.Product, context.Profile!, request.Preparation.Manifest, request.Preparation.ManifestHash, plan.PilotRoot, plan.StartupScriptPath, plan.StartupScriptSha256, plan.SafetyDecision, request.Preparation.Configuration.LaunchMode).PlanId, StringComparison.Ordinal))
return new(false, null, "PILOT_PLAN_CHANGED", null, null); return new(false, null, "PILOT_PLAN_CHANGED", null, null);
var sessionId = Guid.NewGuid().ToString("N"); var sessionId = Guid.NewGuid().ToString("N");
@@ -76,7 +82,7 @@ public sealed class PilotExecutionService(
var packageRoot = Path.Combine(plan.PilotRoot, "packages", plan.PackageId); var packageRoot = Path.Combine(plan.PilotRoot, "packages", plan.PackageId);
var versionDirectory = plan.VersionDirectory; var versionDirectory = plan.VersionDirectory;
var staging = Path.Combine(plan.PilotRoot, "staging", transactionId); var staging = Path.Combine(plan.PilotRoot, "staging", transactionId);
var scriptPath = Path.Combine(plan.PilotRoot, "startup", sessionId + ".scr"); var scriptPath = plan.StartupScriptPath;
var snapshotPath = Path.Combine(plan.PilotRoot, "state", "transactions", transactionId + ".json"); var snapshotPath = Path.Combine(plan.PilotRoot, "state", "transactions", transactionId + ".json");
Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!); Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!);
var currentPath = Path.Combine(packageRoot, "current.json"); var currentPath = Path.Combine(packageRoot, "current.json");
@@ -85,7 +91,7 @@ public sealed class PilotExecutionService(
var previous = await ReadTextOrNull(previousPath, 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); var snapshot = new ActivationSnapshot(transactionId, plan.PackageId, plan.PackageVersion, current, Hash(current), previous, Hash(previous), versionDirectory, scriptPath, staging);
await WriteJsonAtomic(snapshotPath, snapshot, cancellationToken); 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); await WriteSession(plan.PilotRoot, new PersistedSession(2, sessionId, transactionId, "Activating", plan.PackageId, plan.PackageVersion, plan.ManifestSha256, plan.PilotRoot, versionDirectory, scriptPath, request.Preparation.Configuration.LaunchMode.ToString(), resolvedPaths.Values, plan.Process.Arguments, null, snapshotPath, null, null, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null), cancellationToken);
try try
{ {
Directory.CreateDirectory(staging); Directory.CreateDirectory(staging);
@@ -103,13 +109,20 @@ public sealed class PilotExecutionService(
if (Directory.Exists(versionDirectory)) Directory.Delete(staging, true); else Directory.Move(staging, 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(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);
var automation = request.Preparation.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
if (automation)
{
var paths = resolver.Resolve(new LauncherPathVariables(request.Preparation.Configuration.PathVariables), [LauncherPathVariableNames.AutomationScriptPath, LauncherPathVariableNames.FixtureOrthoDwg, LauncherPathVariableNames.LocalSessionRoot]);
scriptPath = paths.Require(LauncherPathVariableNames.AutomationScriptPath);
if (!Path.GetFullPath(scriptPath).StartsWith(Path.GetFullPath(paths.Require(LauncherPathVariableNames.LocalSessionRoot)).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AUTOMATION_SCRIPT_OUTSIDE_SESSION_ROOT");
Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!); Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
var script = request.Preparation.Configuration.AutomationMode is null var script = scriptBuilder.BuildAutomationScript(new(versionDirectory, request.Preparation.Manifest.EntryAssembly, paths.Require(LauncherPathVariableNames.FixtureOrthoDwg), scriptPath));
? 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 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); var arguments = new AcadArgumentBuilder().Build(request.Preparation.Configuration.LaunchMode, automation ? scriptPath : null);
var actualScriptHash = automation ? Convert.ToHexString(SHA256.HashData(await File.ReadAllBytesAsync(scriptPath, cancellationToken))) : null;
await WriteSession(plan.PilotRoot, (await ReadSession(plan.PilotRoot, cancellationToken))! with { State = "ProcessStarting", StartupScriptPath = automation ? scriptPath : string.Empty, AcadArguments = arguments, ActualStartupScriptSha256 = actualScriptHash, UpdatedAtUtc = DateTimeOffset.UtcNow }, cancellationToken);
var result = new GuardedAutodeskProcessLauncher(gate, probe, invoker).Start(context, plan with { StartupScriptPath = automation ? scriptPath : string.Empty, Process = plan.Process with { Arguments = arguments, WorkingDirectory = plan.PilotRoot } }, request.Permit, permits);
if (!result.Started) if (!result.Started)
{ {
await MarkFailed(plan.PilotRoot, result.BlockCode ?? "PROCESS_START_FAILED", cancellationToken); await MarkFailed(plan.PilotRoot, result.BlockCode ?? "PROCESS_START_FAILED", cancellationToken);
+25
View File
@@ -0,0 +1,25 @@
# Launcher path resolution
`PathResolver` is the only resolver for launcher path variables. Configuration
is layered in this order: `launcher.session.json`, `launcher.machine.json`,
then `launcher.defaults.json`; built-in defaults are non-path safety defaults.
Corporate and local locations are variables, not source-code paths. The
supported variable names are: `CorporateDfsRoot`, `CorporateLauncherRoot`,
`CorporateCatalogRoot`, `CorporatePackagesRoot`, `CorporateProfilesRoot`,
`CorporatePalettesRoot`, `CorporateFontsRoot`, `CorporateTemplatesRoot`,
`CorporateScriptsRoot`, `ProjectName`, `DisciplineName`, `ProjectRoot`,
`DisciplineRoot`, `PlantProjectFile`, `ProjectRosterPath`,
`ProjectApplicationsRoot`, `ProjectPaletteOverlayRoot`, `LocalLauncherRoot`,
`LocalPackageCache`, `LocalActiveRoot`, `LocalSessionRoot`,
`LocalEvidenceRoot`, `LocalLogsRoot`, `LocalRollbackRoot`, `AcadExecutable`,
`PRecEntryAssembly`, `FixtureOrthoDwg`, `AutomationScriptPath`,
`AutomationConfigPath`, `AutomationEvidencePath`, and `AutomationPermitPath`.
Undefined corporate locations remain `null` / `NOT VERIFIED`; the resolver does
not invent a DFS hierarchy. The legacy `A:` drive is accepted only as input and
is canonicalized through configured `CorporateDfsRoot`; it is never emitted.
`Production` launch arguments are `/product PLNT3D /language ru-RU` only.
`NextPilotPreview`, `NextPilotApply`, and `LifecycleProbe` require a resolved
script beneath the local session root and add exactly one `/b` argument.
+1 -1
View File
@@ -2,6 +2,6 @@
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 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 pilot uses only `${LocalLauncherRoot}` resolved from local configuration. 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. 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.
+5 -1
View File
@@ -1,6 +1,10 @@
# Pilot local configuration # Pilot local configuration
Local file: `C:\AutoCAD User Support\LauncherNextPilot\config\pilot.local.json`. It is never auto-created or tracked. Unknown/corrupt configuration blocks pilot mode. Configuration files are `${LocalLauncherRoot}\config\launcher.defaults.json`,
`launcher.machine.json`, and `launcher.session.json`. They are never
auto-created or tracked. Unknown/corrupt configuration blocks pilot mode;
session values take precedence over machine values, which take precedence over
corporate defaults.
```json ```json
{"schemaVersion":1,"launcherMode":"NextPilot","pilotEnabled":true,"allowProcessStart":false,"requireExplicitExecute":true,"pluginExecutionMode":"Smoke","pilotRoot":"C:\\AutoCAD User Support\\LauncherNextPilot","selectedProductId":null,"selectedProfileName":null,"executionPermitLifetimeSeconds":300} {"schemaVersion":1,"launcherMode":"NextPilot","pilotEnabled":true,"allowProcessStart":false,"requireExplicitExecute":true,"pluginExecutionMode":"Smoke","pilotRoot":"C:\\AutoCAD User Support\\LauncherNextPilot","selectedProductId":null,"selectedProfileName":null,"executionPermitLifetimeSeconds":300}
@@ -19,7 +19,7 @@ public sealed class ControlledExecutionTests
[Fact] public void ForceAfterEvidence_RequiresTestAutomation() [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); } { 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() [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); } { 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",FixtureMarkerPath="C:\\test\\fixture.marker",LaunchMode=AcadLaunchMode.NextPilotPreview }; 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()
@@ -0,0 +1,96 @@
using Launcher.Next.Autodesk;
using Launcher.Next.Core;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class PathAndLaunchTests
{
private static readonly IReadOnlyDictionary<string, string?> Paths = new Dictionary<string, string?>
{
[LauncherPathVariableNames.CorporateDfsRoot] = "\\\\phimproject.com\\a",
[LauncherPathVariableNames.ProjectName] = "Проект",
[LauncherPathVariableNames.DisciplineName] = "Plant 3D",
[LauncherPathVariableNames.ProjectRoot] = "${CorporateDfsRoot}\\${ProjectName}",
[LauncherPathVariableNames.DisciplineRoot] = "${ProjectRoot}/${DisciplineName}",
[LauncherPathVariableNames.LocalLauncherRoot] = "C:\\Launcher Pilot",
[LauncherPathVariableNames.LocalSessionRoot] = "${LocalLauncherRoot}\\sessions",
[LauncherPathVariableNames.AcadExecutable] = "C:\\Plant 3D 2026\\acad.exe",
[LauncherPathVariableNames.FixtureOrthoDwg] = "C:\\Fixture Space\\Тест.dwg",
[LauncherPathVariableNames.AutomationScriptPath] = "${LocalSessionRoot}\\preview.scr",
[LauncherPathVariableNames.AutomationConfigPath] = "${LocalSessionRoot}\\preview.json"
};
private static PathResolver Resolver(IReadOnlyDictionary<string, string?>? env = null) => new(env ?? new Dictionary<string, string?>());
[Fact] public void PathResolver_ResolvesSimpleVariable() => Assert.Equal("C:\\Launcher Pilot", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.LocalLauncherRoot));
[Fact] public void PathResolver_ResolvesNestedVariables() => Assert.Equal("\\\\phimproject.com\\a\\Проект\\Plant 3D", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.DisciplineRoot));
[Fact] public void PathResolver_ResolvesEnvironmentVariable() => Assert.Equal("C:\\From Environment", Resolver(new Dictionary<string, string?> { ["ROOT"] = "C:\\From Environment" }).Resolve(new(new Dictionary<string, string?> { ["Value"] = "${ROOT}" })).Require("Value"));
[Fact] public void PathResolver_RejectsUnknownVariable() => Assert.Throws<InvalidOperationException>(() => Resolver().Resolve(new(new Dictionary<string, string?> { ["Value"] = "${Missing}" })));
[Fact] public void PathResolver_RejectsEmptyRequiredVariable() => Assert.Throws<InvalidOperationException>(() => Resolver().Resolve(new(new Dictionary<string, string?> { ["Value"] = "" }), ["Value"]));
[Fact] public void PathResolver_RejectsCycle() => Assert.Throws<InvalidOperationException>(() => Resolver().Resolve(new(new Dictionary<string, string?> { ["A"] = "${B}", ["B"] = "${A}" })));
[Fact] public void PathResolver_PreservesUnicode() => Assert.Contains("Проект", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.ProjectRoot));
[Fact] public void PathResolver_NormalizesSeparators() => Assert.DoesNotContain('/', Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.DisciplineRoot));
[Fact] public void PathResolver_NormalizesLegacyDriveAToCorporateDfs() => Assert.Equal("\\\\phimproject.com\\a\\Project\\Plant", Resolver().ResolvePath("A:\\Project\\Plant", new(Paths)));
[Fact] public void PathResolver_DoesNotEmitDriveA() => Assert.False(Resolver().ResolvePath("A:\\Project", new(Paths)).StartsWith("A:\\", StringComparison.OrdinalIgnoreCase));
[Fact] public void PathResolver_RejectsTraversalOutsideRoot() => Assert.Throws<InvalidOperationException>(() => Resolver().ResolvePath("${LocalSessionRoot}\\..\\escape", new(Paths), "${LocalSessionRoot}"));
[Fact] public void PathResolver_RequiresAbsolutePath() => Assert.Throws<InvalidOperationException>(() => Resolver().ResolvePath("relative\\file", new(Paths)));
[Fact] public void ProjectRoot_UsesProjectAndDisciplineVariables() => Assert.Equal("\\\\phimproject.com\\a\\Проект", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.ProjectRoot));
[Fact] public void CorporateDfsRoot_ComesFromConfiguration() => Assert.Equal("\\\\phimproject.com\\a", Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.CorporateDfsRoot));
[Fact] public void ConfigurationLayers_SessionOverridesMachineOverridesDefaults()
{
var result = LauncherConfigurationLayers.Load("{\"SchemaVersion\":1,\"PathVariables\":{\"LocalLauncherRoot\":\"C:\\\\defaults\",\"AcadExecutable\":\"C:\\\\acad.exe\"}}", "{\"SchemaVersion\":1,\"PathVariables\":{\"LocalLauncherRoot\":\"C:\\\\machine\"}}", "{\"SchemaVersion\":1,\"PilotEnabled\":true,\"PathVariables\":{\"LocalSessionRoot\":\"C:\\\\session\"}}");
Assert.True(result.IsValid); Assert.Equal("C:\\machine", result.Configuration.PathVariables[LauncherPathVariableNames.LocalLauncherRoot]); Assert.True(result.Configuration.PilotEnabled);
}
[Fact] public void ProductionExecute_DoesNotAddBatchScript() => Assert.DoesNotContain("/b", new AcadArgumentBuilder().Build(AcadLaunchMode.Production, null));
[Fact] public void ProductionExecute_RejectsAutomationScriptPath() => Assert.Throws<InvalidOperationException>(() => new AcadArgumentBuilder().Build(AcadLaunchMode.Production, "C:\\session\\a.scr"));
[Fact] public void ProductionExecute_DoesNotCreateScript() => Assert.DoesNotContain(new AcadArgumentBuilder().Build(AcadLaunchMode.Production, null), x => x.EndsWith(".scr", StringComparison.OrdinalIgnoreCase));
[Fact] public void ProductionExecute_DoesNotSetAutomationEnvironment() => Assert.DoesNotContain("P_REC_ANNOT_AUTOMATION", new Dictionary<string, string> { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }.Keys);
[Fact] public void PreviewExecute_RequiresBatchScript() => Assert.Throws<InvalidOperationException>(() => new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, null));
[Fact] public void PreviewExecute_AddsExactlyOneBatchArgument() => Assert.Single(new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, "C:\\session\\preview.scr"), x => x == "/b");
[Fact] public void PreviewExecute_UsesResolvedScriptPath() => Assert.Contains(Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.AutomationScriptPath), new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, Resolver().Resolve(new(Paths)).Require(LauncherPathVariableNames.AutomationScriptPath)));
[Fact] public void PreviewExecute_RejectsPathOutsideSessionRoot() => Assert.Throws<InvalidOperationException>(() => Resolver().ResolvePath("C:\\elsewhere\\preview.scr", new(Paths), "${LocalSessionRoot}"));
[Fact] public void PreviewExecute_RequiresFixtureMarker() => Assert.Contains("AUTOMATION_CONFIGURATION_INVALID", new NextPilotSafetyGate().Evaluate(Context(new PilotLocalConfiguration { AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = "C:\\session\\automation.json", LaunchMode = AcadLaunchMode.NextPilotPreview }, false), false).Violations);
[Fact] public void PreviewExecute_RequiresProductionDisabled() => Assert.Contains("AUTOMATION_CONFIGURATION_INVALID", new NextPilotSafetyGate().Evaluate(Context(new PilotLocalConfiguration { AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = "C:\\session\\automation.json", FixtureMarkerPath = "C:\\session\\fixture.marker", LaunchMode = AcadLaunchMode.NextPilotPreview, ProductionDeploy = true }, false), false).Violations);
[Fact] public void AcadArguments_ProductionContainProductAndLanguage() { var a = new AcadArgumentBuilder().Build(AcadLaunchMode.Production, null); Assert.Equal(["/product", "PLNT3D", "/language", "ru-RU"], a); }
[Fact] public void AcadArguments_AutomationContainProductLanguageAndBatch() { var a = new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, "C:\\session\\preview.scr"); Assert.Equal(["/product", "PLNT3D", "/language", "ru-RU", "/b", "C:\\session\\preview.scr"], a); }
[Fact] public void AcadArguments_DoNotContainProfileOrWorkspaceYet() { var a = new AcadArgumentBuilder().Build(AcadLaunchMode.NextPilotPreview, "C:\\session\\preview.scr"); Assert.DoesNotContain("/p", a); Assert.DoesNotContain("/w", a); }
[Fact] public void PreviewScript_IsDeterministicAndContainsRequiredCommandOrder()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root);
var entry = "PRecAnnot.dll"; var fixture = Path.Combine(root, "Fixture Орто.dwg"); File.WriteAllText(Path.Combine(root, entry), string.Empty); File.WriteAllText(fixture, string.Empty);
try
{
var paths = new ResolvedAutomationScriptPaths(root, entry, fixture, Path.Combine(root, "preview.scr"));
var first = new StartupScriptBuilder().BuildAutomationScript(paths); var second = new StartupScriptBuilder().BuildAutomationScript(paths);
Assert.Equal(first, second); Assert.EndsWith("\n", first); Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(first, "PXP-OA").Count);
Assert.True(first.IndexOf("PHP_REC_AUTOMATION_PREPARE", StringComparison.Ordinal) < first.IndexOf("PXP-OA", StringComparison.Ordinal));
Assert.True(first.LastIndexOf("PXP-OA", StringComparison.Ordinal) < first.IndexOf("PHP_REC_AUTOMATION_FINALIZE", StringComparison.Ordinal));
}
finally { Directory.Delete(root, true); }
}
[Fact] public void ProductionSource_HasNoForbiddenPathLiterals()
{
var root = FindRoot();
var forbidden = new[] { "A:\\", "\\\\phimproject.com\\a", "C:\\AutoCAD User Support", "C:\\CAD User Support", "C:\\dev\\" };
var production = new[] { "Launcher.Next.App", "Launcher.Next.Core", "Launcher.Next.Infrastructure", "Launcher.Next.Autodesk" }
.SelectMany(folder => Directory.GetFiles(Path.Combine(root, folder), "*.cs", SearchOption.AllDirectories));
foreach (var file in production)
{
foreach (var text in forbidden)
{
if (Path.GetFileName(file) == "LauncherPaths.cs" && text == "A:\\") continue; // explicit legacy-input alias parser exception
Assert.DoesNotContain(text, File.ReadAllText(file), StringComparison.OrdinalIgnoreCase);
}
}
}
private static PilotSafetyContext Context(PilotLocalConfiguration configuration, bool execution) => new(configuration with { SchemaVersion = 1, LauncherMode = LauncherMode.NextPilot, PilotEnabled = true, RequireExplicitExecute = true, AllowProcessStart = execution }, new("C:\\acad.exe", "2026", "x64", []), new("", false, true, ["test"]), new(true, "C:\\Pilot", []), ValidationResult.Success, true, false, false);
private static string FindRoot()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "Launcher.Next.sln"))) directory = directory.Parent;
return directory!.FullName;
}
}