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 == "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<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");
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<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)
{
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<string,string> ContentHashes(string marker) { var root = Path.GetDirectoryName(marker)!; return Directory.GetFiles(root, "*", SearchOption.AllDirectories).Where(x => x.EndsWith(".dwg", StringComparison.OrdinalIgnoreCase) || x.EndsWith(".dcf", StringComparison.OrdinalIgnoreCase) || Path.GetFileName(x).Equals("Project.xml", StringComparison.OrdinalIgnoreCase)).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToDictionary(x => Path.GetRelativePath(root,x), x => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(x))), StringComparer.OrdinalIgnoreCase); }
private static bool HashesEqual(IReadOnlyDictionary<string,string> x, IReadOnlyDictionary<string,string> y) => x.Count == y.Count && x.All(p => y.TryGetValue(p.Key, out var value) && value == p.Value);
private static PilotExecutionService CreateService() => new(new PackageManifestValidator(), new PilotRootPolicy(@"C:\AutoCAD User Support"), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker());
private static 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<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");
}
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 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<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)
{
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)
{ 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";
}
}
+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 IRollbackManager { Task<string?> 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<string, object?>? fields = null); }
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? 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<string, string?> PathVariables { get; init; } = new Dictionary<string, string?>();
public static PilotLocalConfiguration SafeDefault { get; } = new();
}
public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe }
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> 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<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 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 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<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); }
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<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 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,
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<string, string?> ResolvedPaths, IReadOnlyList<string> 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<PilotPreparationResult> 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<string, string> { ["P_REC_ANNOT_RUNTIME_MODE"] = request.Configuration.PluginExecutionMode.ToString() };
if (!string.IsNullOrWhiteSpace(request.Configuration.ApplyPermitPath))
environment["P_REC_ANNOT_APPLY_PERMIT_PATH"] = request.Configuration.ApplyPermitPath;
if (request.Configuration.AutomationMode is not null)
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<PilotExecutionResult> 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);
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 = 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));
var script = scriptBuilder.BuildAutomationScript(new(versionDirectory, request.Preparation.Manifest.EntryAssembly, paths.Require(LauncherPathVariableNames.FixtureOrthoDwg), scriptPath));
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)
{
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 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.
+5 -1
View File
@@ -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}
@@ -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()
@@ -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;
}
}