diff --git a/Launcher.Next.App/PilotCli.cs b/Launcher.Next.App/PilotCli.cs index bb068a7..7582c7b 100644 --- a/Launcher.Next.App/PilotCli.cs +++ b/Launcher.Next.App/PilotCli.cs @@ -28,14 +28,15 @@ internal static class PilotCli if (command == "status") return await StatusAsync(root); if (command == "rollback") return await RollbackAsync(root); if (!TryReadConfiguration(root, out var config, out error)) return Fail(Blocked, error!); - if (!string.Equals(config.PilotRoot, root, StringComparison.OrdinalIgnoreCase)) return Fail(Blocked, "PILOT_ROOT_CONFIGURATION_MISMATCH"); + if (!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!); Directory.CreateDirectory(Path.Combine(root, "catalog")); await File.WriteAllTextAsync(Path.Combine(root, "catalog", "P_Rec_Annot.manifest.json"), JsonSerializer.Serialize(manifest)); - if (!File.Exists(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe")) return Fail(DiscoveryFailed, "PLANT3D_2026_NOT_FOUND"); + if (!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); - var service = CreateService(); - var product = new ProductInstallation(@"C:\Program Files\Autodesk\AutoCAD 2026\acad.exe", "2026", "x64", []); + var service = CreateService(paths); + 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 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)); @@ -86,6 +87,7 @@ internal static class PilotCli { 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 (!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 (!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"); @@ -95,7 +97,7 @@ internal static class PilotCli 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 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); if (!previewSession.Started || !previewSession.RollbackVerified || !previewSession.EvidenceCompleted) return Fail(ProcessFailed, "AUTOMATION_PREVIEW_SESSION_FAILED"); var preview = JsonSerializer.Deserialize(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"); 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 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 hashesEqual = HashesEqual(before, ContentHashes(options.FixtureMarker)); var apply = File.Exists(applyEvidence) ? JsonSerializer.Deserialize(await File.ReadAllTextAsync(applyEvidence), new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) : null; @@ -118,7 +120,8 @@ internal static class PilotCli private static async Task 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 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); @@ -166,7 +169,7 @@ internal static class PilotCli } private static Dictionary ContentHashes(string marker) { var root = Path.GetDirectoryName(marker)!; return Directory.GetFiles(root, "*", SearchOption.AllDirectories).Where(x => x.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".dcf", StringComparison.OrdinalIgnoreCase) || Path.GetFileName(x).Equals("Project.xml", StringComparison.OrdinalIgnoreCase)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToDictionary(x => Path.GetRelativePath(root,x), x => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(x))), StringComparer.OrdinalIgnoreCase); } private static bool HashesEqual(IReadOnlyDictionary x, IReadOnlyDictionary y) => x.Count == y.Count && x.All(p => y.TryGetValue(p.Key, out var value) && value == p.Value); - private static PilotExecutionService CreateService() => new(new PackageManifestValidator(), new PilotRootPolicy(@"C:\AutoCAD User Support"), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker()); + private static 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 bool HasCompletedEvidence(string path) { @@ -175,7 +178,8 @@ internal static class PilotCli } private static async Task 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"); } private static async Task 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 bool TryReadConfiguration(string root, out PilotLocalConfiguration config, out string? error) { - config = PilotLocalConfiguration.SafeDefault; error = null; var path = Path.Combine(root, "config", "pilot.local.json"); - if (!File.Exists(path)) { error = "PILOT_CONFIGURATION_MISSING"; return false; } - var parsed = PilotConfigurationParser.Parse(File.ReadAllText(path)); config = parsed.Configuration; + 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(machine)) { error = "PILOT_CONFIGURATION_MISSING"; return false; } + var parsed = LauncherConfigurationLayers.LoadFromFiles(defaults, machine, session); config = parsed.Configuration; if (!parsed.IsValid) { error = "PILOT_CONFIGURATION_INVALID"; return false; } 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(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(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) { manifest = null; hash = null; error = null; diff --git a/Launcher.Next.Autodesk/AutodeskAdapters.cs b/Launcher.Next.Autodesk/AutodeskAdapters.cs index cc8edf1..3bd95e2 100644 --- a/Launcher.Next.Autodesk/AutodeskAdapters.cs +++ b/Launcher.Next.Autodesk/AutodeskAdapters.cs @@ -16,12 +16,12 @@ 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) + 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"); 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"; } } diff --git a/Launcher.Next.Core/Contracts.cs b/Launcher.Next.Core/Contracts.cs index 80efd12..76d62b0 100644 --- a/Launcher.Next.Core/Contracts.cs +++ b/Launcher.Next.Core/Contracts.cs @@ -28,7 +28,8 @@ public interface ILocalPackageStore { Task GetCurrentVersionAsync(strin public interface IPackageActivator { Task ActivateAsync(string packageId, string version, CancellationToken cancellationToken); } public interface IRollbackManager { Task RollbackAsync(string packageId, CancellationToken cancellationToken); } 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 ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary? fields = null); } public interface IClock { DateTimeOffset UtcNow { get; } } diff --git a/Launcher.Next.Core/LauncherPaths.cs b/Launcher.Next.Core/LauncherPaths.cs new file mode 100644 index 0000000..5c7d9e1 --- /dev/null +++ b/Launcher.Next.Core/LauncherPaths.cs @@ -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 Values) +{ + public string? this[string name] => Values.TryGetValue(name, out var value) ? value : null; +} + +public sealed record ResolvedLauncherPaths(IReadOnlyDictionary 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? required = null); + string ResolvePath(string value, LauncherPathVariables variables, string? constrainedRoot = null); +} + +public sealed class PathResolver(IReadOnlyDictionary? environment = null) : IPathResolver +{ + private static readonly Regex Token = new(@"\$\{(?[A-Za-z][A-Za-z0-9_]*)\}", RegexOptions.Compiled); + private readonly IReadOnlyDictionary environment = environment ?? Environment.GetEnvironmentVariables() + .Cast().ToDictionary(x => (string)x.Key, x => x.Value?.ToString(), StringComparer.OrdinalIgnoreCase); + + public ResolvedLauncherPaths Resolve(LauncherPathVariables variables, IEnumerable? required = null) + { + var resolved = new Dictionary(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(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 resolved, HashSet 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 resolved, HashSet 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 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(); + } + } +} diff --git a/Launcher.Next.Core/NextPilot.cs b/Launcher.Next.Core/NextPilot.cs index aa96f6e..8aa62e2 100644 --- a/Launcher.Next.Core/NextPilot.cs +++ b/Launcher.Next.Core/NextPilot.cs @@ -19,6 +19,7 @@ public sealed record PilotLocalConfiguration public string? ApplyPermitPath { get; init; } public string? AutomationMode { 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 // persisted completed automation evidence. It never represents graceful exit. public bool ForceAfterEvidence { get; init; } @@ -27,8 +28,12 @@ public sealed record PilotLocalConfiguration public string? SelectedProductId { get; init; } public string? SelectedProfileName { get; init; } public int ExecutionPermitLifetimeSeconds { get; init; } = 300; + public AcadLaunchMode LaunchMode { get; init; } = AcadLaunchMode.Production; + public bool ProductionDeploy { get; init; } + public IReadOnlyDictionary PathVariables { get; init; } = new Dictionary(); public static PilotLocalConfiguration SafeDefault { get; } = new(); } +public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe } public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList Errors) { public bool IsValid => Errors.Count == 0; } public static class PilotConfigurationParser @@ -67,14 +72,42 @@ public interface INextPilotSafetyGate { PilotSafetyDecision Evaluate(PilotSafety public sealed class NextPilotSafetyGate : INextPilotSafetyGate { public PilotSafetyDecision Evaluate(PilotSafetyContext c, bool forExecution) - { var v = new List(); 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(); + 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 Arguments, string WorkingDirectory, IReadOnlyDictionary 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 class AcadArgumentBuilder +{ + public IReadOnlyList Build(AcadLaunchMode mode, string? automationScriptPath) + { + var arguments = new List { "/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 PilotLaunchPlan Create(ProductInstallation product, AutoCadProfileCandidate profile, PackageManifest manifest, string manifestHash, string root, string scriptPath, string scriptHash, PilotSafetyDecision decision) - { var version = Path.Combine(root, "packages", manifest.PackageId, "versions", manifest.Version); var entry = Path.Combine(version, manifest.EntryAssembly); var args = new List { "/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 { ["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); } + 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 AcadArgumentBuilder().Build(mode, mode == AcadLaunchMode.Production ? null : scriptPath); var process = new ProcessLaunchSpecification(product.ExecutablePath, args, Path.GetDirectoryName(product.ExecutablePath)!, new Dictionary { ["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 interface IExecutionPermitService { ExecutionPermit Issue(PilotLaunchPlan plan, int lifetimeSeconds); bool Validate(ExecutionPermit permit, PilotLaunchPlan plan, DateTimeOffset now); } diff --git a/Launcher.Next.Infrastructure/PilotExecutionService.cs b/Launcher.Next.Infrastructure/PilotExecutionService.cs index f3287e5..51050e9 100644 --- a/Launcher.Next.Infrastructure/PilotExecutionService.cs +++ b/Launcher.Next.Infrastructure/PilotExecutionService.cs @@ -13,7 +13,8 @@ public sealed class PilotExecutionService( IStartupScriptBuilder scriptBuilder, IExecutionPermitService permits, IAutoCadProcessProbe probe, - IOperatingSystemProcessInvoker invoker) : IPilotExecutionService + IOperatingSystemProcessInvoker invoker, + IPathResolver? pathResolver = null) : IPilotExecutionService { private sealed record Pointer(string Version); private sealed record ActivationSnapshot( @@ -25,6 +26,7 @@ public sealed class PilotExecutionService( int SchemaVersion, string SessionId, string TransactionId, string State, string PackageId, string PackageVersion, string ManifestSha256, string PilotRoot, string VersionDirectory, string StartupScriptPath, + string LaunchMode, IReadOnlyDictionary ResolvedPaths, IReadOnlyList AcadArguments, string? ActualStartupScriptSha256, string ActivationSnapshotPath, int? ProcessId, DateTimeOffset? ProcessStartUtc, int? ProcessExitCode, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc, string? LastErrorCode, string? LastErrorMessage); @@ -32,28 +34,30 @@ public sealed class PilotExecutionService( public async Task PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken) { 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 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 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); - var futureEntry = Path.Combine(request.PilotRoot, "packages", request.Manifest.PackageId, "versions", request.Manifest.Version, request.Manifest.EntryAssembly); - 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 automation = request.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe; + var content = automation ? "AUTOMATION_SCRIPT_PENDING" : "PRODUCTION_NO_SCRIPT"; 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 { ["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) + if (automation) { environment["P_REC_ANNOT_LAUNCHER_MODE"] = "NextPilot"; - environment["P_REC_ANNOT_AUTOMATION"] = request.Configuration.AutomationMode; - environment["P_REC_ANNOT_AUTOMATION_CONFIG"] = request.Configuration.AutomationConfigurationPath!; + environment["P_REC_ANNOT_AUTOMATION"] = request.Configuration.AutomationMode!; + environment["P_REC_ANNOT_AUTOMATION_CONFIG"] = paths.Require(LauncherPathVariableNames.AutomationConfigPath); } return new(plan with { Process = plan.Process with { EnvironmentVariables = environment } }, safety); } @@ -61,6 +65,8 @@ public sealed class PilotExecutionService( public async Task ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken) { 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 validation = validator.Validate(request.Preparation.Manifest, 2026, "x64"); 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()); var decision = gate.Evaluate(context, true); if (!decision.IsAllowed) return new(false, null, decision.Violations.First(), null, null); - if (!string.Equals(plan.PlanId, PilotPlanFactory.Create(request.Preparation.Product, context.Profile!, request.Preparation.Manifest, request.Preparation.ManifestHash, request.Preparation.PilotRoot, request.Preparation.StartupScriptPath, plan.StartupScriptSha256, plan.SafetyDecision).PlanId, StringComparison.Ordinal)) + 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); var sessionId = Guid.NewGuid().ToString("N"); @@ -76,7 +82,7 @@ public sealed class PilotExecutionService( var packageRoot = Path.Combine(plan.PilotRoot, "packages", plan.PackageId); var versionDirectory = plan.VersionDirectory; var staging = Path.Combine(plan.PilotRoot, "staging", transactionId); - var scriptPath = Path.Combine(plan.PilotRoot, "startup", sessionId + ".scr"); + var scriptPath = plan.StartupScriptPath; var snapshotPath = Path.Combine(plan.PilotRoot, "state", "transactions", transactionId + ".json"); Directory.CreateDirectory(Path.GetDirectoryName(snapshotPath)!); var currentPath = Path.Combine(packageRoot, "current.json"); @@ -85,7 +91,7 @@ public sealed class PilotExecutionService( var previous = await ReadTextOrNull(previousPath, cancellationToken); var snapshot = new ActivationSnapshot(transactionId, plan.PackageId, plan.PackageVersion, current, Hash(current), previous, Hash(previous), versionDirectory, scriptPath, staging); await WriteJsonAtomic(snapshotPath, snapshot, cancellationToken); - await WriteSession(plan.PilotRoot, new PersistedSession(1, sessionId, transactionId, "Activating", plan.PackageId, plan.PackageVersion, plan.ManifestSha256, plan.PilotRoot, versionDirectory, scriptPath, snapshotPath, null, null, null, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null, null), cancellationToken); + 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 { Directory.CreateDirectory(staging); @@ -103,13 +109,20 @@ public sealed class PilotExecutionService( if (Directory.Exists(versionDirectory)) Directory.Delete(staging, true); else Directory.Move(staging, versionDirectory); await WriteTextAtomic(previousPath, current ?? string.Empty, cancellationToken, current is not null); await WriteTextAtomic(currentPath, JsonSerializer.Serialize(new Pointer(plan.PackageVersion)), cancellationToken, true); - Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!); - 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); + 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)!); + var script = scriptBuilder.BuildAutomationScript(new(versionDirectory, request.Preparation.Manifest.EntryAssembly, paths.Require(LauncherPathVariableNames.FixtureOrthoDwg), scriptPath)); + await File.WriteAllTextAsync(scriptPath, script, cancellationToken); + } + 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) { await MarkFailed(plan.PilotRoot, result.BlockCode ?? "PROCESS_START_FAILED", cancellationToken); diff --git a/docs/architecture/path-resolution.md b/docs/architecture/path-resolution.md new file mode 100644 index 0000000..64b9b60 --- /dev/null +++ b/docs/architecture/path-resolution.md @@ -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. diff --git a/docs/architecture/runtime-mechanics.md b/docs/architecture/runtime-mechanics.md index fceda6e..29e1060 100644 --- a/docs/architecture/runtime-mechanics.md +++ b/docs/architecture/runtime-mechanics.md @@ -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 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 `; no profile argument is supplied. The SCR invokes exactly one `NETLOAD`, for the local versioned entry assembly only. diff --git a/docs/pilots/pilot-local-configuration.md b/docs/pilots/pilot-local-configuration.md index 56f6109..7498ee4 100644 --- a/docs/pilots/pilot-local-configuration.md +++ b/docs/pilots/pilot-local-configuration.md @@ -1,6 +1,10 @@ # 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 {"schemaVersion":1,"launcherMode":"NextPilot","pilotEnabled":true,"allowProcessStart":false,"requireExplicitExecute":true,"pluginExecutionMode":"Smoke","pilotRoot":"C:\\AutoCAD User Support\\LauncherNextPilot","selectedProductId":null,"selectedProfileName":null,"executionPermitLifetimeSeconds":300} diff --git a/tests/Launcher.Next.Tests/ControlledExecutionTests.cs b/tests/Launcher.Next.Tests/ControlledExecutionTests.cs index 28d07b6..e57ab4d 100644 --- a/tests/Launcher.Next.Tests/ControlledExecutionTests.cs +++ b/tests/Launcher.Next.Tests/ControlledExecutionTests.cs @@ -19,7 +19,7 @@ public sealed class ControlledExecutionTests [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); } + { 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() { 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() diff --git a/tests/Launcher.Next.Tests/PathAndLaunchTests.cs b/tests/Launcher.Next.Tests/PathAndLaunchTests.cs new file mode 100644 index 0000000..e80a035 --- /dev/null +++ b/tests/Launcher.Next.Tests/PathAndLaunchTests.cs @@ -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 Paths = new Dictionary + { + [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? env = null) => new(env ?? new Dictionary()); + + [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 { ["ROOT"] = "C:\\From Environment" }).Resolve(new(new Dictionary { ["Value"] = "${ROOT}" })).Require("Value")); + [Fact] public void PathResolver_RejectsUnknownVariable() => Assert.Throws(() => Resolver().Resolve(new(new Dictionary { ["Value"] = "${Missing}" }))); + [Fact] public void PathResolver_RejectsEmptyRequiredVariable() => Assert.Throws(() => Resolver().Resolve(new(new Dictionary { ["Value"] = "" }), ["Value"])); + [Fact] public void PathResolver_RejectsCycle() => Assert.Throws(() => Resolver().Resolve(new(new Dictionary { ["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(() => Resolver().ResolvePath("${LocalSessionRoot}\\..\\escape", new(Paths), "${LocalSessionRoot}")); + [Fact] public void PathResolver_RequiresAbsolutePath() => Assert.Throws(() => 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(() => 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 { ["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke" }.Keys); + [Fact] public void PreviewExecute_RequiresBatchScript() => Assert.Throws(() => 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(() => 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; + } +}