Author SHA1 Message Date
codex d5716ea546 docs: define Launcher Next agent contract 2026-08-03 08:12:33 +03:00
36 changed files with 111 additions and 470 deletions
+111
View File
@@ -0,0 +1,111 @@
# AGENTS.md — Launcher Next
## Назначение
Launcher Next — отдельная программа для доставки, activation, запуска и
rollback корпоративных Plant 3D 2026 plugin packages. Она заменяет legacy
цепочку `launcher → P_Serv_Starter → BAT → LISP → OrthoLoader → 20 NETLOAD`:
`Launcher Next → manifest → local staging → SHA-256 validation → versioned activation → one startup NETLOAD → Plant 3D 2026 → entry assembly → wait → automatic rollback`.
Репозиторий `C:\dev\launcher-next` независим. Legacy launcher repositories
используются только read-only.
## Приложение и режимы
Существует одна видимая программа: `Launcher.Next.App`. Не создавать отдельные
test/pilot launchers с отдельным composition root; Pilot CLI — режим того же
приложения.
Launcher modes: `Legacy`, `DryRun`, `NextPilot`, `NextPreferred`. Допустимые
plugin modes: `Smoke`, `DryRun`; Apply требует отдельного задания. Без explicit
pilot configuration безопасные defaults: `DryRun`, `PilotEnabled=false`,
`AllowProcessStart=false`. Pilot CLI читает реальную local configuration, а не
создаёт safe-default вместо отсутствующего файла.
На test machine реальный Process.Start разрешён, если задача требует runtime
проверку и safety gates выполнены. Не ставить лишний review barrier после
foundation/interface/composition root/Prepare: задача real pilot должна дойти
до Execute, process, checkpoint, ProcessExited, automatic rollback,
`RolledBack`, documentation и push.
## Plant 3D launch contract
Подтверждённый Plant 3D 2026 contract:
```text
/product PLNT3D /language ru-RU /b <generated-startup-script>
```
Не передавать `/p`, пока profile не доказан отдельным runtime evidence. Всегда
использовать `ProcessStartInfo.ArgumentList`. Передавать
`P_REC_ANNOT_RUNTIME_MODE=Smoke` или `DryRun` из pilot configuration.
## Pilot root, manifest и package
Разрешённый isolated root: `C:\AutoCAD User Support\LauncherNextPilot`. Он не
смешивается с `C:\AutoCAD User Support\Applications`. Запрещены UNC/relative
roots, Program Files, Autodesk ApplicationPlugins, repository directory, path
traversal, symlink/junction escape и writes в legacy root.
Manifest содержит schema, package ID/version, product/year/series,
architecture, entry assembly, rollout channel, rollback safety, relative files,
length и SHA-256. Блокировать Autodesk DLL, PDB, test/legacy assemblies,
absolute/path-traversal paths, missing entry assembly, changed files и hash
mismatch.
## Prepare, Execute и startup script
`PrepareAsync` читает configuration, discovers product, validates manifest and
source, creates deterministic LaunchPlan/script content/process specification и
не создаёт activation state/script/process.
`ExecuteAsync` повторно проверяет gate и one-time permit, сохраняет
ActivationSnapshot, использует unique staging, повторно проверяет hashes,
атомарно activates version/pointers, materializes script, повторно проверяет
process state, запускает один process и сохраняет PID/session. Не подменять
service ручным PowerShell copying.
Startup script содержит ровно один `_.NETLOAD` только entry assembly из
versioned local directory; он не запускает commands/LISP/OrthoLoader, не меняет
TRUSTEDPATHS, не ссылается на UNC и не NETLOAD internal dependencies.
## Session и rollback
States: `Prepared`, `Activating`, `Activated`, `ProcessStarting`,
`ProcessStarted`, `WaitingForRuntimeCheck`, `ProcessExited`, `RollbackStarted`,
`RolledBack`, `Failed`. Session atomically persisted; permit token никогда не
пишется. Active/incomplete session блокирует второй run.
Automatic rollback выполняется после завершения конкретного PID. Он читает
persisted session/snapshot, восстанавливает exact current/previous pointers,
удаляет startup script и pilot version, очищает staging, идемпотентен и работает
после перезапуска. Проверять фактические файлы, не только сообщение.
CLI: `pilot prepare`, `pilot run`, `pilot status`, `pilot rollback`.
`pilot run --wait-for-exit --rollback-after-exit` не завершается сразу после
Process.Start — он ждёт AutoCAD и rollback.
## Legacy isolation, documentation и tests
Launcher Next не изменяет legacy launcher/P_Serv_Starter/BAT/LISP/
app_netload.lsp/OrthoLoader/Settings.xml, AutoCAD profile, registry или
TRUSTEDPATHS. Допустима read-only hash verification.
Документировать цепочку `CLI → configuration → manifest → validation → Prepare
→ permit → snapshot → staging → activation/pointers → script → Process.Start →
PID → NETLOAD → plugin → wait → ProcessExited → rollback → RolledBack` с
реальными классами/методами.
Tests не запускают AutoCAD; process invoker заменяется fake. Обязательны parser,
gates, manifest/hash, staging/atomic activation, permit, arguments, persistence,
wait, automatic/standalone/idempotent rollback и legacy isolation. Реальный
Plant runtime фиксируется отдельным evidence.
## Definition of Done
Launcher slice завершён, когда normal UI сохранён, CLI вызывает реальный
application service, build без warnings, tests passed, требуемый runtime pilot
выполнен, assembly loaded from versioned directory, PID закрыт штатно, rollback
и `RolledBack` проверены, механика документирована, ветка published обычным
push.
-10
View File
@@ -1,10 +0,0 @@
<Project>
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<PlatformTarget>x64</PlatformTarget>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>
-1
View File
@@ -1 +0,0 @@
<Project />
-1
View File
@@ -1 +0,0 @@
<Application x:Class="Launcher.Next.App.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" StartupUri="MainWindow.xaml" />
-1
View File
@@ -1 +0,0 @@
namespace Launcher.Next.App; public partial class App : System.Windows.Application { }
@@ -1 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><OutputType>WinExe</OutputType><TargetFramework>net8.0-windows</TargetFramework><UseWPF>true</UseWPF><EnableWindowsTargeting>true</EnableWindowsTargeting></PropertyGroup><ItemGroup><ProjectReference Include="..\Launcher.Next.Application\Launcher.Next.Application.csproj" /><ProjectReference Include="..\Launcher.Next.Observability\Launcher.Next.Observability.csproj" /></ItemGroup></Project>
-1
View File
@@ -1 +0,0 @@
<Window x:Class="Launcher.Next.App.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Launcher Next (Foundation)" Height="300" Width="540"><StackPanel Margin="20"><TextBlock FontSize="20" Text="Launcher Next — foundation"/><TextBlock Margin="0,12,0,0" Text="Plant 3D: discovery available in diagnostics"/><TextBlock Text="Mode: Legacy (default); NextPilot requires explicit configuration"/><TextBlock Text="Package: no production catalog connected"/><Button Margin="0,16,0,0" IsEnabled="False" Content="Launch (disabled: dry-run foundation)"/></StackPanel></Window>
-1
View File
@@ -1 +0,0 @@
namespace Launcher.Next.App; public partial class MainWindow : System.Windows.Window { public MainWindow() => InitializeComponent(); }
@@ -1 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><ProjectReference Include="..\Launcher.Next.Core\Launcher.Next.Core.csproj" /></ItemGroup></Project>
@@ -1,14 +0,0 @@
using Launcher.Next.Core;
namespace Launcher.Next.Application;
public sealed class PrepareLaunchUseCase(IProductDiscovery discovery, IAutodeskProcessLauncher launcher, ILauncherTelemetry telemetry)
{
public PreparedLaunch Execute(int year, string profile, string scriptPath)
{
telemetry.Write("ProductDiscoveryStarted");
var product = discovery.DiscoverPlant3D(year) ?? throw new InvalidOperationException("PLANT_PRODUCT_NOT_FOUND");
telemetry.Write("ProductDiscovered", new Dictionary<string, object?> { ["version"] = product.ProductVersion, ["architecture"] = product.Architecture });
var prepared = launcher.Prepare(new(product, profile, scriptPath, true));
telemetry.Write("LaunchPrepared", new Dictionary<string, object?> { ["dryRun"] = true });
return prepared;
}
}
@@ -1,36 +0,0 @@
using Microsoft.Win32;
using Launcher.Next.Core;
namespace Launcher.Next.Autodesk;
public interface IRegistryReader { object? GetValue(string path, string name); }
public sealed class WindowsRegistryReader : IRegistryReader { public object? GetValue(string path, string name) => Registry.LocalMachine.OpenSubKey(path)?.GetValue(name); }
public sealed class PlantProductDiscovery(IRegistryReader registry) : IProductDiscovery
{
public ProductInstallation? DiscoverPlant3D(int year)
{
var install = registry.GetValue($@"SOFTWARE\Autodesk\AutoCAD\R25.1\ACAD-xxxx:409", "InstallDir") as string;
if (string.IsNullOrWhiteSpace(install)) return null; var exe = Path.Combine(install, "acad.exe");
return File.Exists(exe) ? new(exe, year.ToString(), "x64", []) : null;
}
}
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 sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
{
public PreparedLaunch Prepare(LaunchRequest r)
{
if (!r.DryRun) throw new InvalidOperationException("PROCESS_START_DISABLED_IN_FOUNDATION");
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = r.Product.ExecutablePath,
WorkingDirectory = Path.GetDirectoryName(r.Product.ExecutablePath)!,
UseShellExecute = false
};
startInfo.ArgumentList.Add("/product"); startInfo.ArgumentList.Add("PLNT3D");
startInfo.ArgumentList.Add("/p"); startInfo.ArgumentList.Add(r.Profile);
startInfo.ArgumentList.Add("/b"); startInfo.ArgumentList.Add(r.StartupScriptPath);
return new(startInfo.FileName, startInfo.ArgumentList.ToArray(), startInfo.WorkingDirectory, false);
}
}
@@ -1 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk"><PropertyGroup><TargetFramework>net8.0-windows</TargetFramework></PropertyGroup><ItemGroup><ProjectReference Include="..\Launcher.Next.Core\Launcher.Next.Core.csproj" /></ItemGroup></Project>
-35
View File
@@ -1,35 +0,0 @@
namespace Launcher.Next.Core;
public enum LauncherMode { Legacy, DryRun, NextPilot, NextPreferred }
public enum PluginExecutionMode { Smoke, DryRun }
public sealed record ProductInstallation(string ExecutablePath, string ProductVersion, string Architecture, IReadOnlyList<string> Profiles);
public sealed record PackageFile(string Path, long Length, string Sha256, bool Optional = false);
public sealed record StartupDefinition(string Type, string Command);
public sealed record PackageManifest(
int SchemaVersion, string PackageId, string Version, string Product, int ProductYear,
string Architecture, string EntryAssembly, IReadOnlyList<PackageFile> Files, StartupDefinition Startup,
string? MinimumLauncherVersion = null, IReadOnlyList<string>? CompatibleAutoCadSeries = null,
string? RequiredRuntime = null, IReadOnlyList<string>? Dependencies = null,
IReadOnlyList<string>? OptionalResources = null, string? RolloutChannel = null,
bool Disabled = false, bool RollbackSafe = false, string? ReleaseNotesReference = null);
public sealed record ValidationIssue(string Code, string Message);
public sealed record ValidationResult(IReadOnlyList<ValidationIssue> Issues)
{ public bool IsValid => Issues.Count == 0; public static ValidationResult Success { get; } = new([]); }
public sealed record PackageProgress(string Stage, int Completed, int Total);
public sealed record ActivationResult(string CurrentVersion, string? PreviousVersion);
public sealed record LaunchRequest(ProductInstallation Product, string Profile, string StartupScriptPath, bool DryRun);
public sealed record PreparedLaunch(string Executable, IReadOnlyList<string> Arguments, string WorkingDirectory, bool WouldStart);
public interface IProductDiscovery { ProductInstallation? DiscoverPlant3D(int year); }
public interface IPackageCatalogClient { Task<PackageManifest?> GetManifestAsync(string packageId, CancellationToken cancellationToken); }
public interface IPackageManifestValidator { ValidationResult Validate(PackageManifest manifest, int productYear, string architecture); }
public interface IPackageSynchronizer { Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress<PackageProgress>? progress = null); }
public interface ILocalPackageStore { Task<string?> GetCurrentVersionAsync(string packageId, CancellationToken cancellationToken); Task<string?> GetPreviousVersionAsync(string packageId, CancellationToken cancellationToken); }
public interface IPackageActivator { Task<ActivationResult> ActivateAsync(string packageId, string version, CancellationToken cancellationToken); }
public interface IRollbackManager { Task<string?> RollbackAsync(string packageId, CancellationToken cancellationToken); }
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); }
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
public interface IClock { DateTimeOffset UtcNow { get; } }
public interface IFileHasher { Task<string> Sha256Async(string path, CancellationToken cancellationToken); }
@@ -1 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk" />
-27
View File
@@ -1,27 +0,0 @@
using System.Text.RegularExpressions;
namespace Launcher.Next.Core;
public sealed class PackageManifestValidator : IPackageManifestValidator
{
private static readonly string[] Forbidden = ["acmgd.dll", "acdbmgd.dll", "accoremgd.dll", "adwindows.dll"];
public ValidationResult Validate(PackageManifest manifest, int productYear, string architecture)
{
var issues = new List<ValidationIssue>();
if (manifest.SchemaVersion != 1) issues.Add(new("schema", "Unsupported schemaVersion."));
if (string.IsNullOrWhiteSpace(manifest.PackageId) || string.IsNullOrWhiteSpace(manifest.Version)) issues.Add(new("identity", "packageId and version are required."));
if (!string.Equals(manifest.Product, "Plant3D", StringComparison.OrdinalIgnoreCase) || manifest.ProductYear != productYear) issues.Add(new("product", "Package is incompatible with Plant 3D."));
if (!string.Equals(manifest.Architecture, architecture, StringComparison.OrdinalIgnoreCase)) issues.Add(new("architecture", "Package architecture is incompatible."));
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var file in manifest.Files)
{
if (string.IsNullOrWhiteSpace(file.Path) || Path.IsPathRooted(file.Path) || file.Path.Split(['/', '\\']).Any(x => x is ".." or "")) issues.Add(new("path", $"Invalid package path: {file.Path}"));
if (!paths.Add(file.Path)) issues.Add(new("duplicate", $"Duplicate package path: {file.Path}"));
var name = Path.GetFileName(file.Path);
if (Forbidden.Contains(name, StringComparer.OrdinalIgnoreCase) || name.StartsWith("PnP", StringComparison.OrdinalIgnoreCase) || name.StartsWith("Autodesk.ProcessPower", StringComparison.OrdinalIgnoreCase)) issues.Add(new("autodesk", $"Forbidden Autodesk assembly: {name}"));
if (!Regex.IsMatch(file.Sha256 ?? string.Empty, "^[A-Fa-f0-9]{64}$")) issues.Add(new("hash", $"Invalid SHA-256 for {file.Path}"));
}
if (!paths.Contains(manifest.EntryAssembly)) issues.Add(new("entry", "entryAssembly must be listed in files."));
return issues.Count == 0 ? ValidationResult.Success : new ValidationResult(issues);
}
}
-72
View File
@@ -1,72 +0,0 @@
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<string> 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<PilotLocalConfiguration>(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<string> 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<string> Evidence);
public interface IAutoCadProfileDiscovery { IReadOnlyList<AutoCadProfileCandidate> DiscoverProfiles(ProductInstallation product); }
public static class ProfileSelector
{
public static AutoCadProfileCandidate? Select(IReadOnlyList<AutoCadProfileCandidate> 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<string> 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<string>(); 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<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 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 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<Guid> 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); }
@@ -1,7 +0,0 @@
using System.Text.Json;
namespace Launcher.Next.Core;
public static class PackageManifestJson
{
private static readonly JsonSerializerOptions Options = new() { PropertyNameCaseInsensitive = true };
public static PackageManifest Parse(string json) => JsonSerializer.Deserialize<PackageManifest>(json, Options) ?? throw new InvalidOperationException("MANIFEST_EMPTY");
}
@@ -1,13 +0,0 @@
namespace Launcher.Next.Core;
public sealed record PilotPreparationRequest(PilotLocalConfiguration Configuration, ProductInstallation Product, PackageManifest Manifest, string ManifestHash, string PackageSourceRoot, string PilotRoot, string StartupScriptPath);
public sealed record PilotPreparationResult(PilotLaunchPlan? Plan, PilotSafetyDecision SafetyDecision);
public sealed record PilotExecutionRequest(PilotPreparationRequest Preparation, PilotLaunchPlan Plan, ExecutionPermit Permit);
public sealed record PilotExecutionResult(bool Started, int? ProcessId, string? BlockCode, string? VersionDirectory, string? StartupScriptPath);
public sealed record PilotRollbackRequest(string PilotRoot, string PackageId, string? ActivatedVersion, string StartupScriptPath, string? PreviousCurrentContent);
public sealed record PilotRollbackResult(bool Completed, string? ErrorCode);
public interface IPilotExecutionService
{
Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest request, CancellationToken cancellationToken);
Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest request, CancellationToken cancellationToken);
Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest request, CancellationToken cancellationToken);
}
@@ -1,14 +0,0 @@
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 = true;
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); }
}
@@ -1 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><ProjectReference Include="..\Launcher.Next.Core\Launcher.Next.Core.csproj" /></ItemGroup></Project>
@@ -1,31 +0,0 @@
using System.Security.Cryptography;
using System.Text.Json;
using Launcher.Next.Core;
namespace Launcher.Next.Infrastructure;
public sealed class Sha256FileHasher : IFileHasher
{ public async Task<string> Sha256Async(string path, CancellationToken cancellationToken) { await using var s = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(s, cancellationToken)); } }
public sealed class LocalPackageStore(string root, IFileHasher hasher) : ILocalPackageStore, IPackageActivator, IRollbackManager, IPackageSynchronizer
{
private sealed record Pointer(string Current, string? Previous);
private string PackageRoot(string id) => Path.Combine(root, "Applications", id);
private string PointerPath(string id) => Path.Combine(PackageRoot(id), "current.json");
private async Task<Pointer?> ReadPointer(string id, CancellationToken ct) => File.Exists(PointerPath(id)) ? await JsonSerializer.DeserializeAsync<Pointer>(File.OpenRead(PointerPath(id)), cancellationToken: ct) : null;
public async Task<string?> GetCurrentVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Current;
public async Task<string?> GetPreviousVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Previous;
public async Task<ActivationResult> ActivateAsync(string id, string version, CancellationToken ct)
{
var dir = Path.Combine(PackageRoot(id), "versions", version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir);
Directory.CreateDirectory(PackageRoot(id)); var prior = await ReadPointer(id, ct);
var temp = PointerPath(id) + ".tmp"; await File.WriteAllTextAsync(temp, JsonSerializer.Serialize(new Pointer(version, prior?.Current)), ct); File.Move(temp, PointerPath(id), true);
return new(version, prior?.Current);
}
public async Task<string?> RollbackAsync(string id, CancellationToken ct) { var p = await ReadPointer(id, ct); return p?.Previous is null ? null : (await ActivateAsync(id, p.Previous, ct)).CurrentVersion; }
public async Task<ActivationResult> SynchronizeAsync(PackageManifest manifest, string sourceRoot, CancellationToken ct, IProgress<PackageProgress>? progress = null)
{
var staging = Path.Combine(root, "Launcher", "staging", manifest.PackageId + "-" + manifest.Version + "-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging);
try { var i = 0; foreach (var f in manifest.Files) { ct.ThrowIfCancellationRequested(); var source = Path.Combine(sourceRoot, f.Path); var dest = Path.GetFullPath(Path.Combine(staging, f.Path)); if (!dest.StartsWith(Path.GetFullPath(staging) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_ESCAPE"); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(source, dest, true); if (!string.Equals(await hasher.Sha256Async(dest, ct), f.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_MISMATCH"); progress?.Report(new("copy", ++i, manifest.Files.Count)); }
var versionDir = Path.Combine(PackageRoot(manifest.PackageId), "versions", manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); if (Directory.Exists(versionDir)) Directory.Delete(staging, true); else Directory.Move(staging, versionDir); return await ActivateAsync(manifest.PackageId, manifest.Version, ct); }
catch { if (Directory.Exists(staging)) Directory.Delete(staging, true); throw; }
}
}
@@ -1,13 +0,0 @@
using System.Security.Cryptography;
using System.Text;
using Launcher.Next.Core;
namespace Launcher.Next.Infrastructure;
public sealed class PilotExecutionService(IPackageManifestValidator validator, IPilotRootPolicy rootPolicy, INextPilotSafetyGate gate, IFileHasher hasher, IStartupScriptBuilder scriptBuilder, IExecutionPermitService permits, IAutoCadProcessProbe probe, IOperatingSystemProcessInvoker invoker) : IPilotExecutionService
{
public Task<PilotPreparationResult> PrepareAsync(PilotPreparationRequest r, CancellationToken ct)
{ ct.ThrowIfCancellationRequested(); var root=rootPolicy.Validate(r.PilotRoot); var validation=validator.Validate(r.Manifest,2026,"x64"); var hashes=ValidateSource(r.Manifest,r.PackageSourceRoot,ct).GetAwaiter().GetResult(); var profile=new AutoCadProfileCandidate("",false,true,["legacy contract: no profile argument"]); var safety=gate.Evaluate(new(r.Configuration,r.Product,profile,root,validation,hashes,false,probe.IsAcadRunning()),false); if(!safety.IsAllowed)return Task.FromResult(new PilotPreparationResult(null,safety)); var futureEntry=Path.Combine(r.PilotRoot,"packages",r.Manifest.PackageId,"versions",r.Manifest.Version,r.Manifest.EntryAssembly); var content="._NETLOAD\n\""+futureEntry.Replace("\\","\\\\")+"\"\n"; var scriptHash=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))); var plan=PilotPlanFactory.Create(r.Product,profile,r.Manifest,r.ManifestHash,r.PilotRoot,r.StartupScriptPath,scriptHash,safety); return Task.FromResult(new PilotPreparationResult(plan,safety)); }
public async Task<PilotExecutionResult> ExecuteAsync(PilotExecutionRequest r, CancellationToken ct)
{ var plan=r.Plan; var root=rootPolicy.Validate(r.Preparation.PilotRoot); var valid=validator.Validate(r.Preparation.Manifest,2026,"x64"); var hashes=await ValidateSource(r.Preparation.Manifest,r.Preparation.PackageSourceRoot,ct); var context=new PilotSafetyContext(r.Preparation.Configuration,r.Preparation.Product,new("",false,true,["legacy contract"]),root,valid,hashes,false,probe.IsAcadRunning()); var safety=gate.Evaluate(context,true); if(!safety.IsAllowed)return new(false,null,safety.Violations.First(),null,null); if(!permits.Validate(r.Permit,plan,DateTimeOffset.UtcNow))return new(false,null,"EXECUTION_PERMIT_MISSING",null,null); var version=Path.Combine(plan.PilotRoot,"packages",plan.PackageId,"versions",plan.PackageVersion); var staging=Path.Combine(plan.PilotRoot,"staging",Guid.NewGuid().ToString("N")); Directory.CreateDirectory(staging); try { foreach(var f in r.Preparation.Manifest.Files){ct.ThrowIfCancellationRequested();var src=Path.Combine(r.Preparation.PackageSourceRoot,f.Path);var dst=Path.Combine(staging,f.Path);Directory.CreateDirectory(Path.GetDirectoryName(dst)!);File.Copy(src,dst,true);if(!string.Equals(await hasher.Sha256Async(dst,ct),f.Sha256,StringComparison.OrdinalIgnoreCase))throw new InvalidOperationException("HASH_VALIDATION_FAILED");} Directory.CreateDirectory(Path.GetDirectoryName(version)!);if(!Directory.Exists(version))Directory.Move(staging,version);else Directory.Delete(staging,true); Directory.CreateDirectory(Path.GetDirectoryName(plan.StartupScriptPath)!);await File.WriteAllTextAsync(plan.StartupScriptPath,scriptBuilder.BuildNetloadScript(version,r.Preparation.Manifest.EntryAssembly),ct); var guarded=new GuardedAutodeskProcessLauncher(gate,probe,invoker);var result=guarded.Start(context,plan,r.Permit,permits);return new(result.Started,result.ProcessId,result.BlockCode,version,plan.StartupScriptPath);}catch{if(Directory.Exists(staging))Directory.Delete(staging,true);throw;} }
public Task<PilotRollbackResult> RollbackAsync(PilotRollbackRequest r,CancellationToken ct){try{if(File.Exists(r.StartupScriptPath))File.Delete(r.StartupScriptPath);if(r.ActivatedVersion is not null){var d=Path.Combine(r.PilotRoot,"packages",r.PackageId,"versions",r.ActivatedVersion);if(Directory.Exists(d))Directory.Delete(d,true);}return Task.FromResult(new PilotRollbackResult(true,null));}catch{return Task.FromResult(new PilotRollbackResult(false,"ROLLBACK_FAILED"));}}
private async Task<bool> ValidateSource(PackageManifest m,string root,CancellationToken ct){foreach(var f in m.Files){var p=Path.Combine(root,f.Path);if(!File.Exists(p)||!string.Equals(await hasher.Sha256Async(p,ct),f.Sha256,StringComparison.OrdinalIgnoreCase))return false;}return true;}
}
@@ -1,8 +0,0 @@
using System.Text.Json;
using Launcher.Next.Core;
namespace Launcher.Next.Observability;
public sealed class JsonlTelemetry(string logRoot) : ILauncherTelemetry
{
public void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null)
{ try { Directory.CreateDirectory(logRoot); var allowed = (fields ?? new Dictionary<string, object?>()).Where(x => !x.Key.Contains("user", StringComparison.OrdinalIgnoreCase) && !x.Key.Contains("token", StringComparison.OrdinalIgnoreCase) && !x.Key.Contains("password", StringComparison.OrdinalIgnoreCase)).ToDictionary(); var line = JsonSerializer.Serialize(new { Timestamp = DateTimeOffset.UtcNow, EventType = eventType, Fields = allowed }); File.AppendAllText(Path.Combine(logRoot, DateTime.UtcNow.ToString("yyyyMMdd") + ".jsonl"), line + Environment.NewLine); } catch { } }
}
@@ -1 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk"><ItemGroup><ProjectReference Include="..\Launcher.Next.Core\Launcher.Next.Core.csproj" /></ItemGroup></Project>
-54
View File
@@ -1,54 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Core", "Launcher.Next.Core\Launcher.Next.Core.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Application", "Launcher.Next.Application\Launcher.Next.Application.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Infrastructure", "Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Autodesk", "Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Observability", "Launcher.Next.Observability\Launcher.Next.Observability.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.App", "Launcher.Next.App\Launcher.Next.App.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Launcher.Next.Tests", "tests\Launcher.Next.Tests\Launcher.Next.Tests.csproj", "{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64
Release|x64 = Release|x64
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E001}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E002}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E003}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E004}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E005}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E006}.Release|x64.Build.0 = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}.Debug|x64.ActiveCfg = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}.Debug|x64.Build.0 = Debug|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}.Release|x64.ActiveCfg = Release|x64
{1A4B9D18-8D2B-4A13-BB4C-9F47A1A9E007}.Release|x64.Build.0 = Release|x64
EndGlobalSection
EndGlobal
@@ -1 +0,0 @@
{"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}
-13
View File
@@ -1,13 +0,0 @@
# Foundation scope
Launcher Next is intentionally non-deploying. Package synchronization accepts only a supplied local test source; no production UNC client is wired. Process launch preparation is dry-run only, and the WPF launch button is disabled. The next authorized slice is registry discovery validation and a non-production package catalog pilot.
`tests/Launcher.Next.Tests/fixtures/p-rec-annot-pilot.manifest.json` is a synthetic P_Rec_Annot contract. Its all-zero hash is deliberately replaced by a hash of a test-created one-byte placeholder; it does not identify or package a real Autodesk plugin.
Run from this directory:
```powershell
dotnet restore .\Launcher.Next.sln
dotnet build .\Launcher.Next.sln --configuration Release
dotnet test .\tests\Launcher.Next.Tests\Launcher.Next.Tests.csproj --configuration Release --no-build
```
@@ -1,3 +0,0 @@
# 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.
-3
View File
@@ -1,3 +0,0 @@
# 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.
-7
View File
@@ -1,7 +0,0 @@
# 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}
```
-3
View File
@@ -1,3 +0,0 @@
# 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.
-3
View File
@@ -1,3 +0,0 @@
# 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.
@@ -1,20 +0,0 @@
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=false,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("PROCESS_START_DISABLED",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"); }
}
@@ -1,39 +0,0 @@
using System.Security.Cryptography;
using Launcher.Next.Autodesk;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class FoundationTests
{
private static PackageManifest Manifest(params PackageFile[] files) => new(1, "P_Rec_Annot", "2026.1.0", "Plant3D", 2026, "x64", "PRecAnnot.Plant3D2026.dll", files, new("netload", "PHP_REC_ANNOT"));
private static PackageFile Package(string path, string hash = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") => new(path, 1, hash);
[Fact] public void Manifest_ValidPackage_IsAccepted() => Assert.True(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_PathTraversal_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("../evil.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_AbsolutePath_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("C:\\evil.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_MissingEntryAssembly_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("Other.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_AutodeskAssembly_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll"), Package("AcMgd.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_DuplicateFile_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll"), Package("PRecAnnot.Plant3D2026.dll")), 2026, "x64").IsValid);
[Fact] public void Manifest_WrongArchitecture_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")), 2026, "x86").IsValid);
[Fact] public void Manifest_WrongProductYear_IsRejected() => Assert.False(new PackageManifestValidator().Validate(Manifest(Package("PRecAnnot.Plant3D2026.dll")), 2025, "x64").IsValid);
[Fact] public async Task Synchronizer_UsesStaging_AndRetainsPreviousVersion()
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); var bytes = new byte[] { 7 }; await System.IO.File.WriteAllBytesAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), bytes); var hash = Convert.ToHexString(SHA256.HashData(bytes)); var store = new LocalPackageStore(root, new Sha256FileHasher()); var result = await store.SynchronizeAsync(Manifest(new PackageFile("PRecAnnot.Plant3D2026.dll", 1, hash)), source, CancellationToken.None); Assert.Equal("2026.1.0", result.CurrentVersion); Assert.True(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", result.CurrentVersion))); Directory.Delete(root, true); }
[Fact] public async Task Synchronizer_HashMismatch_IsRejected()
{ var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "source"); Directory.CreateDirectory(source); await System.IO.File.WriteAllTextAsync(Path.Combine(source, "PRecAnnot.Plant3D2026.dll"), "x"); var store = new LocalPackageStore(root, new Sha256FileHasher()); await Assert.ThrowsAsync<InvalidOperationException>(() => store.SynchronizeAsync(Manifest(Package("PRecAnnot.Plant3D2026.dll")), source, CancellationToken.None)); Assert.False(Directory.Exists(Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "2026.1.0"))); Directory.Delete(root, true); }
[Fact] public void StartupScript_ContainsOneNetload_AndEscapesSpaces()
{ var root = Path.Combine(Path.GetTempPath(), "space root", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); System.IO.File.WriteAllText(Path.Combine(root, "PRecAnnot.Plant3D2026.dll"), ""); var s = new StartupScriptBuilder().BuildNetloadScript(root, "PRecAnnot.Plant3D2026.dll"); Assert.Single(System.Text.RegularExpressions.Regex.Matches(s, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); Assert.Contains("space root", s); Directory.Delete(Path.GetDirectoryName(root)!, true); }
[Fact] public void ProcessLauncher_DryRun_DoesNotStartProcess()
{ var p = new DryRunAutodeskProcessLauncher().Prepare(new LaunchRequest(new ProductInstallation("C:\\acad.exe", "2026", "x64", []), "Pilot", "C:\\script.scr", true)); Assert.False(p.WouldStart); }
[Fact] public void Core_HasNoAutodeskOrProcessReferences() => Assert.DoesNotContain(typeof(PackageManifest).Assembly.GetReferencedAssemblies(), x => x.Name!.Contains("Autodesk") || x.Name.Contains("Diagnostics.Process"));
[Fact] public async Task PRecAnnotPilot_DryRun_ValidatesActivatesAndPreparesWithoutStarting()
{
var fixture = await System.IO.File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "fixtures", "p-rec-annot-pilot.manifest.json"));
var parsed = PackageManifestJson.Parse(fixture); var bytes = new byte[] { 7 }; var hash = Convert.ToHexString(SHA256.HashData(bytes));
var manifest = parsed with { Files = [new PackageFile(parsed.EntryAssembly, bytes.Length, hash)] };
Assert.True(new PackageManifestValidator().Validate(manifest, 2026, "x64").IsValid);
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); var source = Path.Combine(root, "fixture"); Directory.CreateDirectory(source); await System.IO.File.WriteAllBytesAsync(Path.Combine(source, manifest.EntryAssembly), bytes);
try { var store = new LocalPackageStore(root, new Sha256FileHasher()); var active = await store.SynchronizeAsync(manifest, source, CancellationToken.None); var packageRoot = Path.Combine(root, "Applications", manifest.PackageId, "versions", active.CurrentVersion); var script = new StartupScriptBuilder().BuildNetloadScript(packageRoot, manifest.EntryAssembly); var launch = new DryRunAutodeskProcessLauncher().Prepare(new LaunchRequest(new("C:\\acad.exe", "2026", "x64", []), "Pilot", script, true)); Assert.False(launch.WouldStart); Assert.Single(System.Text.RegularExpressions.Regex.Matches(script, "netload", System.Text.RegularExpressions.RegexOptions.IgnoreCase).Cast<object>()); }
finally { Directory.Delete(root, true); }
}
}
@@ -1,14 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup><TargetFramework>net8.0-windows</TargetFramework><IsPackable>false</IsPackable></PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2"><PrivateAssets>all</PrivateAssets></PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Launcher.Next.Core\Launcher.Next.Core.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" />
</ItemGroup>
<ItemGroup><None Update="fixtures\p-rec-annot-pilot.manifest.json" CopyToOutputDirectory="PreserveNewest" /></ItemGroup>
</Project>
@@ -1,19 +0,0 @@
{
"schemaVersion": 1,
"packageId": "P_Rec_Annot",
"version": "2026.1.0-pilot",
"product": "Plant3D",
"productYear": 2026,
"architecture": "x64",
"entryAssembly": "PRecAnnot.Plant3D2026.dll",
"files": [
{
"path": "PRecAnnot.Plant3D2026.dll",
"length": 1,
"sha256": "0000000000000000000000000000000000000000000000000000000000000000"
}
],
"startup": { "type": "netload", "command": "PHP_REC_ANNOT" },
"rolloutChannel": "pilot",
"rollbackSafe": true
}