feat(headless): establish portable Linux host boundary

Add the presentation-free acdream-headless executable, strict no-connect configuration validation, dependency and assembly guards, and a Windows/Ubuntu CI lane that builds and tests only the portable runtime closure.

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-27 01:10:45 +02:00
parent 953c469cac
commit aada8a37c1
14 changed files with 613 additions and 8 deletions

View file

@ -0,0 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<AssemblyName>acdream-headless</AssemblyName>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Headless.Tests" />
</ItemGroup>
<ItemGroup>
<!-- The production headless process enters through the same Runtime root
as the graphical client. Presentation, UI, native-window, GL, and
audio projects are forbidden here and enforced by tests. -->
<ProjectReference Include="..\AcDream.Runtime\AcDream.Runtime.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace AcDream.Headless.Configuration;
internal sealed class HeadlessConfiguration
{
[JsonRequired]
public int Version { get; init; }
[JsonRequired]
public List<HeadlessSessionDescriptor?> Sessions { get; init; } = [];
}
internal sealed class HeadlessSessionDescriptor
{
[JsonRequired]
public string Id { get; init; } = string.Empty;
}

View file

@ -0,0 +1,9 @@
namespace AcDream.Headless.Configuration;
internal sealed class HeadlessConfigurationException : Exception
{
internal HeadlessConfigurationException(string message)
: base(message)
{
}
}

View file

@ -0,0 +1,68 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AcDream.Headless.Configuration;
internal static class HeadlessConfigurationLoader
{
private const int CurrentVersion = 1;
private static readonly JsonSerializerOptions Options = new()
{
AllowTrailingCommas = false,
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Disallow,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
};
internal static HeadlessConfiguration Load(string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
string fullPath = Path.GetFullPath(path);
using FileStream stream = File.OpenRead(fullPath);
HeadlessConfiguration? configuration =
JsonSerializer.Deserialize<HeadlessConfiguration>(
stream,
Options);
if (configuration is null)
{
throw new HeadlessConfigurationException(
"The configuration document is empty.");
}
if (configuration.Version != CurrentVersion)
{
throw new HeadlessConfigurationException(
$"Unsupported configuration version {configuration.Version}; "
+ $"expected {CurrentVersion}.");
}
if (configuration.Sessions is null)
{
throw new HeadlessConfigurationException(
"sessions must be an array.");
}
var sessionIds = new HashSet<string>(StringComparer.Ordinal);
foreach (HeadlessSessionDescriptor? session in configuration.Sessions)
{
if (session is null
|| string.IsNullOrWhiteSpace(session.Id))
{
throw new HeadlessConfigurationException(
"Every session requires a non-empty id.");
}
if (!sessionIds.Add(session.Id))
{
throw new HeadlessConfigurationException(
$"Duplicate session id '{session.Id}'.");
}
}
return configuration;
}
}

View file

@ -0,0 +1,6 @@
namespace AcDream.Headless;
/// <summary>
/// Identifies the presentation-free production host assembly.
/// </summary>
internal static class HeadlessAssemblyMarker;

View file

@ -0,0 +1,82 @@
using AcDream.Headless.Configuration;
namespace AcDream.Headless;
internal static class HeadlessEntryPoint
{
private const string HelpText =
"""
acdream-headless
Usage:
acdream-headless --help
acdream-headless validate --config <path>
Commands:
validate Validate a versioned headless configuration without connecting.
Secrets are never accepted on the command line.
""";
internal static int Run(
IReadOnlyList<string> arguments,
TextWriter output,
TextWriter error)
{
ArgumentNullException.ThrowIfNull(arguments);
ArgumentNullException.ThrowIfNull(output);
ArgumentNullException.ThrowIfNull(error);
if (arguments.Count == 0
|| IsHelp(arguments[0]))
{
output.WriteLine(HelpText);
return (int)HeadlessExitCode.Success;
}
if (!string.Equals(
arguments[0],
"validate",
StringComparison.Ordinal))
{
error.WriteLine("Unknown command. Run --help for usage.");
return (int)HeadlessExitCode.UsageError;
}
if (arguments.Count != 3
|| !string.Equals(
arguments[1],
"--config",
StringComparison.Ordinal))
{
error.WriteLine(
"validate requires exactly: --config <path>");
return (int)HeadlessExitCode.UsageError;
}
try
{
HeadlessConfiguration configuration =
HeadlessConfigurationLoader.Load(arguments[2]);
output.WriteLine(
$"Configuration valid: version {configuration.Version}, "
+ $"{configuration.Sessions.Count} session(s).");
return (int)HeadlessExitCode.Success;
}
catch (Exception exception)
when (exception is IOException
or UnauthorizedAccessException
or ArgumentException
or NotSupportedException
or System.Text.Json.JsonException
or HeadlessConfigurationException)
{
error.WriteLine($"Configuration invalid: {exception.Message}");
return (int)HeadlessExitCode.ConfigurationError;
}
}
private static bool IsHelp(string argument) =>
string.Equals(argument, "--help", StringComparison.Ordinal)
|| string.Equals(argument, "-h", StringComparison.Ordinal);
}

View file

@ -0,0 +1,8 @@
namespace AcDream.Headless;
internal enum HeadlessExitCode
{
Success = 0,
UsageError = 2,
ConfigurationError = 3,
}

View file

@ -0,0 +1,3 @@
using AcDream.Headless;
return HeadlessEntryPoint.Run(args, Console.Out, Console.Error);