From 5a146589943a791f649a1e50b5b739019b303cac Mon Sep 17 00:00:00 2001 From: codex Date: Mon, 10 Aug 2026 15:03:38 +0300 Subject: [PATCH] feat: add guarded controlled entity selection transport --- Launcher.Next.Autodesk/AutodeskAdapters.cs | 23 +++++++++++++++---- Launcher.Next.Core/Contracts.cs | 2 +- Launcher.Next.Core/NextPilot.cs | 15 ++++++++++++ .../PilotExecutionService.cs | 2 +- .../Launcher.Next.Tests/PathAndLaunchTests.cs | 18 +++++++++++++++ 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/Launcher.Next.Autodesk/AutodeskAdapters.cs b/Launcher.Next.Autodesk/AutodeskAdapters.cs index ca5df19..847466b 100644 --- a/Launcher.Next.Autodesk/AutodeskAdapters.cs +++ b/Launcher.Next.Autodesk/AutodeskAdapters.cs @@ -28,14 +28,27 @@ public sealed class StartupScriptBuilder : IStartupScriptBuilder var diagnostic = paths.Diagnostic ? "_.CMDECHO\n1\n_.NOMUTT\n0\n_.LOGFILEMODE\n1\n" : string.Empty; - var commands = paths.Apply - ? "_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK\n" - : paths.Diagnostic - ? "PHP_REC_AUTOMATION_BEFORE_PXP_1\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_1\nPHP_REC_AUTOMATION_BEFORE_PXP_2\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_2\n" - : "PXP-OA\nPXP-OA\n"; + var commands = paths.Selection is null ? BuildDefaultCommands(paths) : BuildSelectedEntityCommands(paths); return dialogFreeOpen + "_.OPEN\n" + quote(fixture) + "\n" + diagnostic + BuildNetloadScript(paths.ValidatedPackageRoot, paths.EntryAssembly) + "PHP_REC_AUTOMATION_PREPARE\n" + commands + "PHP_REC_AUTOMATION_FINALIZE\n"; } + + private static string BuildDefaultCommands(ResolvedAutomationScriptPaths paths) => paths.Apply + ? "_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK\n" + : paths.Diagnostic + ? "PHP_REC_AUTOMATION_BEFORE_PXP_1\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_1\nPHP_REC_AUTOMATION_BEFORE_PXP_2\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_2\n" + : "PXP-OA\nPXP-OA\n"; + + private static string BuildSelectedEntityCommands(ResolvedAutomationScriptPaths paths) + { + var selection = paths.Selection!; + selection.Validate(); + var invoke = "(setq P_REC_ANNOT_SELECTED_ENTITY (handent \"" + selection.ObjectHandle.ToUpperInvariant() + "\"))\n" + + "(if (and P_REC_ANNOT_SELECTED_ENTITY (= (cdr (assoc 0 (entget P_REC_ANNOT_SELECTED_ENTITY))) \"" + selection.ExpectedDxfName.ToUpperInvariant() + "\")) " + + "(command-s \"" + selection.CommandName.ToUpperInvariant() + "\" P_REC_ANNOT_SELECTED_ENTITY) " + + "(progn (prompt \"\\nP_REC_ANNOT_SELECTION_REJECTED\") (quit)))\n"; + return paths.Apply ? "_.UNDO\n_MARK\n" + invoke + invoke + "_.UNDO\n_BACK\n" : invoke + invoke; + } } public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher { diff --git a/Launcher.Next.Core/Contracts.cs b/Launcher.Next.Core/Contracts.cs index 543a60f..1f7def7 100644 --- a/Launcher.Next.Core/Contracts.cs +++ b/Launcher.Next.Core/Contracts.cs @@ -48,7 +48,7 @@ public sealed record CachedPackageResolution(string PackageId, string Version, s public interface ILastKnownGoodPackageResolver { Task ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken); } public interface IPackageRequirementCacheBinder { Task BindAsync(PackageRequirement requirement, string version, CancellationToken cancellationToken); } public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); } -public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string FixtureModelDwg, string AutomationScriptPath, bool Diagnostic, bool Apply); +public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string FixtureModelDwg, string AutomationScriptPath, bool Diagnostic, bool Apply, ControlledEntitySelection? Selection = null); 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); } diff --git a/Launcher.Next.Core/NextPilot.cs b/Launcher.Next.Core/NextPilot.cs index c216368..900fcbb 100644 --- a/Launcher.Next.Core/NextPilot.cs +++ b/Launcher.Next.Core/NextPilot.cs @@ -21,6 +21,10 @@ public sealed record PilotLocalConfiguration public bool AutomationDiagnostic { get; init; } public string? AutomationConfigurationPath { get; init; } public string? FixtureMarkerPath { get; init; } + // Controlled automation may reproduce an actual Editor.GetEntity boundary + // only with this immutable, explicitly typed fixture selection. It is never + // populated for normal production launches. + public ControlledEntitySelection? AutomationSelection { 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; } @@ -34,6 +38,16 @@ public sealed record PilotLocalConfiguration public IReadOnlyDictionary PathVariables { get; init; } = new Dictionary(); public static PilotLocalConfiguration SafeDefault { get; } = new(); } +public sealed record ControlledEntitySelection(string CommandName, string ObjectHandle, string ExpectedDxfName) +{ + public void Validate() + { + if (string.IsNullOrWhiteSpace(CommandName) || !CommandName.All(value => char.IsAsciiLetterOrDigit(value) || value is '-' or '_') || + string.IsNullOrWhiteSpace(ObjectHandle) || ObjectHandle.Length > 32 || !ObjectHandle.All(Uri.IsHexDigit) || + string.IsNullOrWhiteSpace(ExpectedDxfName) || !ExpectedDxfName.All(value => char.IsAsciiLetterOrDigit(value) || value == '_')) + throw new InvalidOperationException("AUTOMATION_ENTITY_SELECTION_INVALID"); + } +} public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe } public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList Errors) { public bool IsValid => Errors.Count == 0; } @@ -83,6 +97,7 @@ public sealed class NextPilotSafetyGate : INextPilotSafetyGate 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"); + try { c.Configuration.AutomationSelection?.Validate(); } catch (InvalidOperationException) { v.Add("AUTOMATION_ENTITY_SELECTION_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"); diff --git a/Launcher.Next.Infrastructure/PilotExecutionService.cs b/Launcher.Next.Infrastructure/PilotExecutionService.cs index 6ee7bfe..917bebc 100644 --- a/Launcher.Next.Infrastructure/PilotExecutionService.cs +++ b/Launcher.Next.Infrastructure/PilotExecutionService.cs @@ -116,7 +116,7 @@ public sealed class PilotExecutionService( 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), paths.Require(LauncherPathVariableNames.FixtureModelDwg), scriptPath, request.Preparation.Configuration.AutomationDiagnostic, request.Preparation.Configuration.LaunchMode == AcadLaunchMode.NextPilotApply)); + var script = scriptBuilder.BuildAutomationScript(new(versionDirectory, request.Preparation.Manifest.EntryAssembly, paths.Require(LauncherPathVariableNames.FixtureOrthoDwg), paths.Require(LauncherPathVariableNames.FixtureModelDwg), scriptPath, request.Preparation.Configuration.AutomationDiagnostic, request.Preparation.Configuration.LaunchMode == AcadLaunchMode.NextPilotApply, request.Preparation.Configuration.AutomationSelection)); await File.WriteAllTextAsync(scriptPath, script, cancellationToken); } var arguments = new AcadArgumentBuilder().Build(request.Preparation.Configuration.LaunchMode, automation ? scriptPath : null); diff --git a/tests/Launcher.Next.Tests/PathAndLaunchTests.cs b/tests/Launcher.Next.Tests/PathAndLaunchTests.cs index 595614b..c1753d6 100644 --- a/tests/Launcher.Next.Tests/PathAndLaunchTests.cs +++ b/tests/Launcher.Next.Tests/PathAndLaunchTests.cs @@ -87,6 +87,24 @@ public sealed class PathAndLaunchTests try { var script = new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "apply.scr"), false, true)); Assert.Contains("_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK", script); } finally { Directory.Delete(root, true); } } + [Fact] + public void SelectedEntityScript_UsesExactHandleAndDxfTypeWithoutCoordinates() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var entry = "P.dll"; var fixture = Path.Combine(root, "fixture.dwg"); var model = Path.Combine(root, "model.dwg"); File.WriteAllText(Path.Combine(root, entry), ""); File.WriteAllText(fixture, ""); File.WriteAllText(model, ""); + try + { + var script = new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "selected.scr"), false, true, new("PXP-AAV", "1a2b", "VIEWPORT"))); + Assert.Contains("(handent \"1A2B\")", script); Assert.Contains("(command-s \"PXP-AAV\" P_REC_ANNOT_SELECTED_ENTITY)", script); Assert.Contains("P_REC_ANNOT_SELECTION_REJECTED", script); Assert.DoesNotContain("_.ZOOM", script); Assert.DoesNotContain("_.POINT", script); + } + finally { Directory.Delete(root, true); } + } + [Fact] + public void SelectedEntityScript_RejectsUnsafeSelectionTokens() + { + var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var entry = "P.dll"; var fixture = Path.Combine(root, "fixture.dwg"); var model = Path.Combine(root, "model.dwg"); File.WriteAllText(Path.Combine(root, entry), ""); File.WriteAllText(fixture, ""); File.WriteAllText(model, ""); + try { Assert.Throws(() => new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "selected.scr"), false, false, new("PXP-AAV;QUIT", "1A2B", "VIEWPORT")))); } + finally { Directory.Delete(root, true); } + } [Fact] public void DiagnosticScript_RecordsExactBytes() { var text = "PXP-OA\n"; Assert.Equal(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))), Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text)))); } [Fact] public void DiagnosticScript_HasTrailingNewline() => Assert.EndsWith("\n", "PHP_REC_AUTOMATION_FINALIZE\n"); [Fact] public void DiagnosticScript_RecordsEncoding() => Assert.Equal("utf-8", System.Text.Encoding.UTF8.WebName);