From 90108d1c069f5c34ec51d4cb2f4a8393095fff88 Mon Sep 17 00:00:00 2001 From: codex Date: Fri, 31 Jul 2026 15:19:11 +0300 Subject: [PATCH] feat: add controlled NextPilot execution pipeline --- Launcher.Next.Core/Contracts.cs | 3 +- Launcher.Next.Core/NextPilot.cs | 72 +++++++++++++++++++ .../GuardedProcessLauncher.cs | 14 ++++ .../controlled-execution-foundation.json | 1 + docs/pilots/controlled-execution-design.md | 3 + docs/pilots/manual-runtime-checkpoint.md | 3 + docs/pilots/pilot-local-configuration.md | 7 ++ docs/pilots/prepare-execute-rollback.md | 3 + docs/pilots/profile-discovery.md | 3 + .../ControlledExecutionTests.cs | 20 ++++++ 10 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 Launcher.Next.Core/NextPilot.cs create mode 100644 Launcher.Next.Infrastructure/GuardedProcessLauncher.cs create mode 100644 docs/evidence/controlled-execution-foundation.json create mode 100644 docs/pilots/controlled-execution-design.md create mode 100644 docs/pilots/manual-runtime-checkpoint.md create mode 100644 docs/pilots/pilot-local-configuration.md create mode 100644 docs/pilots/prepare-execute-rollback.md create mode 100644 docs/pilots/profile-discovery.md create mode 100644 tests/Launcher.Next.Tests/ControlledExecutionTests.cs diff --git a/Launcher.Next.Core/Contracts.cs b/Launcher.Next.Core/Contracts.cs index 2f325cd..6b2d75b 100644 --- a/Launcher.Next.Core/Contracts.cs +++ b/Launcher.Next.Core/Contracts.cs @@ -1,6 +1,7 @@ namespace Launcher.Next.Core; -public enum LauncherMode { Legacy, NextPilot, NextPreferred } +public enum LauncherMode { Legacy, DryRun, NextPilot, NextPreferred } +public enum PluginExecutionMode { Smoke, DryRun } public sealed record ProductInstallation(string ExecutablePath, string ProductVersion, string Architecture, IReadOnlyList Profiles); public sealed record PackageFile(string Path, long Length, string Sha256, bool Optional = false); public sealed record StartupDefinition(string Type, string Command); diff --git a/Launcher.Next.Core/NextPilot.cs b/Launcher.Next.Core/NextPilot.cs new file mode 100644 index 0000000..a9ac17e --- /dev/null +++ b/Launcher.Next.Core/NextPilot.cs @@ -0,0 +1,72 @@ +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Launcher.Next.Core; + +public sealed record PilotLocalConfiguration +{ + public int SchemaVersion { get; init; } + public LauncherMode LauncherMode { get; init; } = LauncherMode.DryRun; + public bool PilotEnabled { get; init; } + public bool AllowProcessStart { get; init; } + public bool RequireExplicitExecute { get; init; } = true; + public PluginExecutionMode PluginExecutionMode { get; init; } = PluginExecutionMode.Smoke; + public string? PilotRoot { get; init; } + public string? SelectedProductId { get; init; } + public string? SelectedProfileName { get; init; } + public int ExecutionPermitLifetimeSeconds { get; init; } = 300; + public static PilotLocalConfiguration SafeDefault { get; } = new(); +} +public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList Errors) +{ public bool IsValid => Errors.Count == 0; } +public static class PilotConfigurationParser +{ + public static PilotConfigurationLoadResult Parse(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return new(PilotLocalConfiguration.SafeDefault, []); + try { var config = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true, UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }); + if (config is null || config.SchemaVersion != 1) return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]); + return new(config, []); } + catch (JsonException) { return new(PilotLocalConfiguration.SafeDefault, ["PILOT_CONFIGURATION_INVALID"]); } + } +} +public sealed record PilotRootValidationResult(bool IsValid, string? NormalizedPath, IReadOnlyList Violations); +public interface IPilotRootPolicy { PilotRootValidationResult Validate(string candidate); } +public sealed class PilotRootPolicy(string trustedRoot, string? testRoot = null) : IPilotRootPolicy +{ + public PilotRootValidationResult Validate(string candidate) + { + if (string.IsNullOrWhiteSpace(candidate) || !Path.IsPathFullyQualified(candidate) || candidate.StartsWith("\\\\", StringComparison.Ordinal) || candidate.Contains(':', StringComparison.Ordinal) && candidate.IndexOf(':') != 1) return new(false, null, ["PILOT_ROOT_UNTRUSTED"]); + var full = Path.GetFullPath(candidate); var permitted = new[] { trustedRoot, testRoot }.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => Path.GetFullPath(x!)); + if (!permitted.Any(x => full.StartsWith(x + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) || full.Contains("Program Files", StringComparison.OrdinalIgnoreCase) || full.Contains("Applications", StringComparison.OrdinalIgnoreCase)) return new(false, null, ["PILOT_ROOT_UNTRUSTED"]); + return new(true, full, []); + } +} +public sealed record AutoCadProfileCandidate(string Name, bool IsCurrent, bool IsPlantSpecific, IReadOnlyList Evidence); +public interface IAutoCadProfileDiscovery { IReadOnlyList DiscoverProfiles(ProductInstallation product); } +public static class ProfileSelector +{ + public static AutoCadProfileCandidate? Select(IReadOnlyList candidates, string? explicitName, out string? violation) + { var eligible = candidates.Where(x => x.IsPlantSpecific && x.Evidence.Count > 0).ToArray(); var selected = explicitName is null ? eligible : eligible.Where(x => string.Equals(x.Name, explicitName, StringComparison.Ordinal)).ToArray(); violation = selected.Length switch { 1 => null, 0 => "PROFILE_NOT_RESOLVED", _ => "PROFILE_AMBIGUOUS" }; return selected.Length == 1 ? selected[0] : null; } +} +public sealed record PilotSafetyContext(PilotLocalConfiguration Configuration, ProductInstallation? Product, AutoCadProfileCandidate? Profile, PilotRootValidationResult Root, ValidationResult Manifest, bool HashesValid, bool AutodeskDllPresent, bool AcadRunning); +public sealed record PilotSafetyDecision(bool IsAllowed, IReadOnlyList Violations); +public interface INextPilotSafetyGate { PilotSafetyDecision Evaluate(PilotSafetyContext context, bool forExecution); } +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.PluginExecutionMode != PluginExecutionMode.Smoke) v.Add("NON_SMOKE_PLUGIN_MODE"); 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 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 process = new ProcessLaunchSpecification(product.ExecutablePath, ["/product", "PLNT3D", "/p", profile.Name, "/b", scriptPath], 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 sealed record ExecutionPermit(string PlanId, string Fingerprint, DateTimeOffset ExpiresAtUtc, Guid Id); +public interface IExecutionPermitService { ExecutionPermit Issue(PilotLaunchPlan plan, int lifetimeSeconds); bool Validate(ExecutionPermit permit, PilotLaunchPlan plan, DateTimeOffset now); } +public sealed class ExecutionPermitService : IExecutionPermitService +{ private readonly HashSet used = []; public ExecutionPermit Issue(PilotLaunchPlan p, int seconds) => new(p.PlanId, p.ManifestSha256 + ":" + p.StartupScriptSha256, DateTimeOffset.UtcNow.AddSeconds(Math.Clamp(seconds, 1, 600)), Guid.NewGuid()); public bool Validate(ExecutionPermit p, PilotLaunchPlan plan, DateTimeOffset now) => !used.Contains(p.Id) && p.ExpiresAtUtc >= now && p.PlanId == plan.PlanId && p.Fingerprint == plan.ManifestSha256 + ":" + plan.StartupScriptSha256 && used.Add(p.Id); } diff --git a/Launcher.Next.Infrastructure/GuardedProcessLauncher.cs b/Launcher.Next.Infrastructure/GuardedProcessLauncher.cs new file mode 100644 index 0000000..5f6d134 --- /dev/null +++ b/Launcher.Next.Infrastructure/GuardedProcessLauncher.cs @@ -0,0 +1,14 @@ +using System.Diagnostics; +using Launcher.Next.Core; +namespace Launcher.Next.Infrastructure; +public sealed record ProcessInvocationResult(bool Started, int? ProcessId, string? BlockCode); +public interface IOperatingSystemProcessInvoker { ProcessInvocationResult Start(ProcessLaunchSpecification specification); } +public interface IAutoCadProcessProbe { bool IsAcadRunning(); } +public sealed class OperatingSystemProcessInvoker : IOperatingSystemProcessInvoker +{ public ProcessInvocationResult Start(ProcessLaunchSpecification s) { var info = new ProcessStartInfo { FileName=s.ExecutablePath, WorkingDirectory=s.WorkingDirectory, UseShellExecute=false }; foreach(var a in s.Arguments) info.ArgumentList.Add(a); foreach(var e in s.EnvironmentVariables) info.Environment[e.Key]=e.Value; var p=Process.Start(info); return new(p is not null,p?.Id,null); } } +public sealed class GuardedAutodeskProcessLauncher(INextPilotSafetyGate gate, IAutoCadProcessProbe probe, IOperatingSystemProcessInvoker invoker) +{ + public static readonly bool RealExecutionReleaseEnabled = false; + public ProcessInvocationResult Start(PilotSafetyContext context, PilotLaunchPlan plan, ExecutionPermit? permit, IExecutionPermitService permits) + { var checkedContext = context with { AcadRunning = probe.IsAcadRunning() }; var decision = gate.Evaluate(checkedContext, true); if (!decision.IsAllowed) return new(false, null, decision.Violations.First()); if (permit is null || !permits.Validate(permit, plan, DateTimeOffset.UtcNow)) return new(false,null,"EXECUTION_PERMIT_MISSING"); if (!RealExecutionReleaseEnabled) return new(false,null,"REAL_EXECUTION_RELEASE_NOT_ENABLED"); return invoker.Start(plan.Process); } +} diff --git a/docs/evidence/controlled-execution-foundation.json b/docs/evidence/controlled-execution-foundation.json new file mode 100644 index 0000000..265fb9f --- /dev/null +++ b/docs/evidence/controlled-execution-foundation.json @@ -0,0 +1 @@ +{"nextPilotModeImplemented":true,"pilotConfigurationImplemented":true,"pilotRootPolicyImplemented":true,"productDiscoveryImplemented":true,"profileDiscoveryImplemented":true,"safetyGateImplemented":true,"prepareImplemented":true,"executionPermitImplemented":true,"isolatedStoreImplemented":true,"atomicActivationImplemented":true,"rollbackImplemented":true,"startupScriptLifecycleImplemented":true,"realProcessAdapterCompiled":true,"realExecutionReleaseEnabled":false,"actualProcessStarted":false,"actualPilotRootCreated":false,"actualPackageActivated":false,"productionDeploy":false} diff --git a/docs/pilots/controlled-execution-design.md b/docs/pilots/controlled-execution-design.md new file mode 100644 index 0000000..f8b6394 --- /dev/null +++ b/docs/pilots/controlled-execution-design.md @@ -0,0 +1,3 @@ +# Controlled NextPilot execution + +`NextPilot` is opt-in. Missing or invalid local configuration defaults to DryRun, with process start disabled and plugin mode Smoke. Prepare builds a deterministic plan only; Execute requires all safety conditions and a one-time, short-lived permit. `RealExecutionReleaseEnabled` is compile-time false in this foundation, so the process adapter always returns `REAL_EXECUTION_RELEASE_NOT_ENABLED` before calling the OS invoker. diff --git a/docs/pilots/manual-runtime-checkpoint.md b/docs/pilots/manual-runtime-checkpoint.md new file mode 100644 index 0000000..6c5d913 --- /dev/null +++ b/docs/pilots/manual-runtime-checkpoint.md @@ -0,0 +1,3 @@ +# Manual runtime checkpoint + +Before any real execution: review the LaunchPlan, explicitly confirm product/profile/root/package, enable real execution only in a separately reviewed commit, rebuild/test, create a dedicated runtime branch, then Prepare, issue permit, Execute, manually validate AutoCAD UI, and rollback. diff --git a/docs/pilots/pilot-local-configuration.md b/docs/pilots/pilot-local-configuration.md new file mode 100644 index 0000000..56f6109 --- /dev/null +++ b/docs/pilots/pilot-local-configuration.md @@ -0,0 +1,7 @@ +# 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. + +```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/docs/pilots/prepare-execute-rollback.md b/docs/pilots/prepare-execute-rollback.md new file mode 100644 index 0000000..8824de2 --- /dev/null +++ b/docs/pilots/prepare-execute-rollback.md @@ -0,0 +1,3 @@ +# Prepare, Execute and rollback + +Prepare performs no package/store/script/process writes. Activation and rollback are covered by the existing versioned local store using unique staging and atomic pointer replacement. Execute re-evaluates safety immediately before the invoker, and remains release-blocked in this slice. diff --git a/docs/pilots/profile-discovery.md b/docs/pilots/profile-discovery.md new file mode 100644 index 0000000..a4a19c3 --- /dev/null +++ b/docs/pilots/profile-discovery.md @@ -0,0 +1,3 @@ +# Read-only profile discovery + +The adapter returns candidates with evidence. An explicit exact selection is accepted; otherwise exactly one confirmed Plant-specific candidate is required. Zero candidates blocks with `PROFILE_NOT_RESOLVED`; multiple candidates block with `PROFILE_AMBIGUOUS`. No profile is created or modified. diff --git a/tests/Launcher.Next.Tests/ControlledExecutionTests.cs b/tests/Launcher.Next.Tests/ControlledExecutionTests.cs new file mode 100644 index 0000000..b349c6f --- /dev/null +++ b/tests/Launcher.Next.Tests/ControlledExecutionTests.cs @@ -0,0 +1,20 @@ +using Launcher.Next.Core; +using Launcher.Next.Infrastructure; +using Xunit; +namespace Launcher.Next.Tests; +public sealed class ControlledExecutionTests +{ + [Fact] public void MissingPilotConfiguration_DefaultsToDryRun() => Assert.Equal(LauncherMode.DryRun, PilotConfigurationParser.Parse(null).Configuration.LauncherMode); + [Fact] public void InvalidPilotConfiguration_BlocksPilot() => Assert.False(PilotConfigurationParser.Parse("{").IsValid); + [Fact] public void PilotRoot_Unc_IsRejected() => Assert.False(new PilotRootPolicy("C:\\AutoCAD User Support").Validate("\\\\server\\share").IsValid); + [Fact] public void PilotRoot_AllowedCorporateChild_IsAccepted() => Assert.True(new PilotRootPolicy("C:\\AutoCAD User Support").Validate("C:\\AutoCAD User Support\\LauncherNextPilot").IsValid); + [Fact] public void SafetyGate_ProcessStartDisabled_BlocksExecute() + { var c=new PilotLocalConfiguration { SchemaVersion=1, LauncherMode=LauncherMode.NextPilot, PilotEnabled=true, PluginExecutionMode=PluginExecutionMode.Smoke }; var decision=new NextPilotSafetyGate().Evaluate(new(c,new("C:\\acad.exe","2026","x64",[]),new("Pilot",false,true,["registry"]),new(true,"C:\\AutoCAD User Support\\LauncherNextPilot",[]),ValidationResult.Success,true,false,false),true); Assert.Contains("PROCESS_START_DISABLED",decision.Violations); } + [Fact] public void 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() + { var p=Plan(); var config=new PilotLocalConfiguration { SchemaVersion=1,LauncherMode=LauncherMode.NextPilot,PilotEnabled=true,AllowProcessStart=true,PluginExecutionMode=PluginExecutionMode.Smoke }; var context=new PilotSafetyContext(config,p.Product,p.Profile,new(true,p.PilotRoot,[]),ValidationResult.Success,true,false,false); var permits=new ExecutionPermitService(); var result=new GuardedAutodeskProcessLauncher(new NextPilotSafetyGate(),new Probe(),new Invoker()).Start(context,p,permits.Issue(p,60),permits); Assert.Equal("REAL_EXECUTION_RELEASE_NOT_ENABLED",result.BlockCode); } + private static PilotLaunchPlan Plan() { var product=new ProductInstallation("C:\\acad.exe","2026","x64",[]); var profile=new AutoCadProfileCandidate("Pilot",false,true,["registry"]); var manifest=new PackageManifest(1,"P","1","Plant3D",2026,"x64","P.dll",[new("P.dll",1,new string('A',64))],new("netload","P")); return PilotPlanFactory.Create(product,profile,manifest,"M","C:\\AutoCAD User Support\\LauncherNextPilot","C:\\script.scr","S",new(true,[])); } + private sealed class Probe : IAutoCadProcessProbe { public bool IsAcadRunning()=>false; } + private sealed class Invoker : IOperatingSystemProcessInvoker { public ProcessInvocationResult Start(ProcessLaunchSpecification _) => throw new Xunit.Sdk.XunitException("must not start"); } +}