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,97 @@
name: Headless portability
on:
pull_request:
paths:
- ".github/workflows/headless-portability.yml"
- "AcDream.slnx"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
- "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**"
- "tests/AcDream.Headless.Tests/**"
push:
paths:
- ".github/workflows/headless-portability.yml"
- "AcDream.slnx"
- "src/AcDream.Core/**"
- "src/AcDream.Core.Net/**"
- "src/AcDream.Content/**"
- "src/AcDream.Plugin.Abstractions/**"
- "src/AcDream.Runtime/**"
- "src/AcDream.Headless/**"
- "tests/AcDream.Core.Tests/**"
- "tests/AcDream.Core.Net.Tests/**"
- "tests/AcDream.Content.Tests/**"
- "tests/AcDream.Runtime.Tests/**"
- "tests/AcDream.Headless.Tests/**"
workflow_dispatch:
permissions:
contents: read
jobs:
portable-headless:
strategy:
fail-fast: false
matrix:
os: [windows-latest, ubuntu-latest]
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install .NET 10
uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Build presentation-free closure
shell: pwsh
run: |
$projects = @(
"src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj",
"src/AcDream.Core/AcDream.Core.csproj",
"src/AcDream.Core.Net/AcDream.Core.Net.csproj",
"src/AcDream.Content/AcDream.Content.csproj",
"src/AcDream.Runtime/AcDream.Runtime.csproj",
"src/AcDream.Headless/AcDream.Headless.csproj"
)
foreach ($project in $projects) {
dotnet build $project -c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
# AcDream.Core.Tests still contains historical App integration fixtures,
# so it is deliberately not a member of this no-App restore lane. Core is
# built directly above and exercised through every portable downstream
# test project below; the complete solution lane retains Core.Tests.
- name: Test presentation-free closure
shell: pwsh
run: |
$projects = @(
"tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj",
"tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj",
"tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj",
"tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj"
)
foreach ($project in $projects) {
dotnet test $project -c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
- name: Verify CLI without connecting
shell: pwsh
run: |
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- --help
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Set-Content -LiteralPath headless-k0.json -Value '{"version":1,"sessions":[]}'
dotnet run --project src/AcDream.Headless/AcDream.Headless.csproj -c Release -- validate --config headless-k0.json
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }

View file

@ -6,6 +6,7 @@
<Project Path="src/AcDream.Content/AcDream.Content.csproj" />
<Project Path="src/AcDream.Core/AcDream.Core.csproj" />
<Project Path="src/AcDream.Core.Net/AcDream.Core.Net.csproj" />
<Project Path="src/AcDream.Headless/AcDream.Headless.csproj" />
<Project Path="src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj" />
<Project Path="src/AcDream.Plugins.Smoke/AcDream.Plugins.Smoke.csproj" />
<Project Path="src/AcDream.Runtime/AcDream.Runtime.csproj" />
@ -23,6 +24,7 @@
<Project Path="tests/AcDream.Core.Tests.Fixtures.HelloPlugin/AcDream.Core.Tests.Fixtures.HelloPlugin.csproj" />
<Project Path="tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj" />
<Project Path="tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj" />
<Project Path="tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj" />
<Project Path="tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj" />
<Project Path="tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj" />
</Folder>

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);

View file

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Headless\AcDream.Headless.csproj" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,134 @@
using System.Text.Json;
using System.Xml.Linq;
using System.Runtime.CompilerServices;
namespace AcDream.Headless.Tests;
public sealed class HeadlessDependencyBoundaryTests
{
private static readonly string[] ForbiddenDependencyPrefixes =
[
"AcDream.App",
"AcDream.UI.",
"Silk.NET",
"OpenAL",
"Arch",
"ImGui",
];
[Fact]
public void HeadlessAssemblyReferencesOnlyTheRuntimeProject()
{
string repositoryRoot = FindRepositoryRoot();
string projectPath = Path.Combine(
repositoryRoot,
"src",
"AcDream.Headless",
"AcDream.Headless.csproj");
var project = XDocument.Load(projectPath);
string projectDirectory = Path.GetDirectoryName(projectPath)!;
string[] actual = project
.Descendants("ProjectReference")
.Select(reference => reference.Attribute("Include")?.Value)
.Where(static include => !string.IsNullOrWhiteSpace(include))
.Select(include => Path.GetFullPath(Path.Combine(
projectDirectory,
include!.Replace(
'\\',
Path.DirectorySeparatorChar))))
.ToArray();
string expected = Path.Combine(
repositoryRoot,
"src",
"AcDream.Runtime",
"AcDream.Runtime.csproj");
Assert.Equal([expected], actual);
Assert.Empty(project.Descendants("PackageReference"));
}
[Fact]
public void HeadlessDependencyClosureHasNoPresentationOrBackendLibraries()
{
string depsPath = Path.ChangeExtension(
typeof(HeadlessDependencyBoundaryTests).Assembly.Location,
".deps.json");
using JsonDocument document =
JsonDocument.Parse(File.ReadAllText(depsPath));
string[] libraries = document.RootElement
.GetProperty("libraries")
.EnumerateObject()
.Select(static library => LibraryName(library.Name))
.ToArray();
Assert.DoesNotContain(libraries, IsForbidden);
}
[Fact]
public void HelpPathLoadsNoPresentationOrBackendAssembly()
{
using var output = new StringWriter();
using var error = new StringWriter();
Assert.Equal(
0,
HeadlessEntryPoint.Run(["--help"], output, error));
string[] loaded = AppDomain.CurrentDomain
.GetAssemblies()
.Select(static assembly =>
assembly.GetName().Name ?? string.Empty)
.ToArray();
Assert.DoesNotContain(loaded, IsForbidden);
}
private static string FindRepositoryRoot(
[CallerFilePath] string sourcePath = "")
{
string[] starts =
{
Path.GetDirectoryName(sourcePath) ?? string.Empty,
Directory.GetCurrentDirectory(),
AppContext.BaseDirectory,
};
foreach (string start in starts)
{
if (string.IsNullOrEmpty(start))
{
continue;
}
DirectoryInfo? directory = new(start);
while (directory is not null)
{
if (File.Exists(Path.Combine(
directory.FullName,
"AcDream.slnx")))
{
return directory.FullName;
}
directory = directory.Parent;
}
}
throw new DirectoryNotFoundException(
"Could not find AcDream.slnx above the working or output directory.");
}
private static string LibraryName(string libraryIdentity)
{
int separatorIndex = libraryIdentity.IndexOf('/');
return separatorIndex < 0
? libraryIdentity
: libraryIdentity[..separatorIndex];
}
private static bool IsForbidden(string assemblyName) =>
ForbiddenDependencyPrefixes.Any(prefix =>
assemblyName.StartsWith(
prefix,
StringComparison.OrdinalIgnoreCase));
}

View file

@ -0,0 +1,109 @@
namespace AcDream.Headless.Tests;
public sealed class HeadlessEntryPointTests
{
[Theory]
[InlineData("--help")]
[InlineData("-h")]
public void HelpIsPresentationFreeAndSuccessful(string argument)
{
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = HeadlessEntryPoint.Run(
[argument],
output,
error);
Assert.Equal(0, exitCode);
Assert.Contains("acdream-headless", output.ToString());
Assert.Contains("Secrets are never accepted", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
[Fact]
public void ValidateAcceptsStrictEmptyNoConnectConfiguration()
{
using var file = TemporaryConfiguration.Create(
"""{"version":1,"sessions":[]}""");
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = HeadlessEntryPoint.Run(
["validate", "--config", file.Path],
output,
error);
Assert.Equal(0, exitCode);
Assert.Contains("0 session(s)", output.ToString());
Assert.Equal(string.Empty, error.ToString());
}
[Theory]
[InlineData("""{"version":2,"sessions":[]}""", "Unsupported configuration version")]
[InlineData("""{"version":1,"sessions":[],"password":"secret"}""", "could not be mapped")]
[InlineData("""{"version":1,"sessions":[{"id":"bot"},{"id":"bot"}]}""", "Duplicate session id")]
[InlineData("""{"version":1,"sessions":[null]}""", "non-empty id")]
[InlineData("""{"version":1}""", "required properties")]
public void ValidateRejectsInvalidOrSecretShapedConfiguration(
string json,
string expectedError)
{
using var file = TemporaryConfiguration.Create(json);
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = HeadlessEntryPoint.Run(
["validate", "--config", file.Path],
output,
error);
Assert.Equal(3, exitCode);
Assert.Contains(
expectedError,
error.ToString(),
StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("secret", error.ToString());
Assert.Equal(string.Empty, output.ToString());
}
[Fact]
public void UnknownCommandReturnsUsageError()
{
using var output = new StringWriter();
using var error = new StringWriter();
int exitCode = HeadlessEntryPoint.Run(
["connect", "--password", "secret"],
output,
error);
Assert.Equal(2, exitCode);
Assert.DoesNotContain("secret", error.ToString());
Assert.Equal(string.Empty, output.ToString());
}
private sealed class TemporaryConfiguration : IDisposable
{
private TemporaryConfiguration(string path)
{
Path = path;
}
internal string Path { get; }
internal static TemporaryConfiguration Create(string json)
{
string path = System.IO.Path.Combine(
System.IO.Path.GetTempPath(),
$"acdream-headless-{Guid.NewGuid():N}.json");
File.WriteAllText(path, json);
return new TemporaryConfiguration(path);
}
public void Dispose()
{
File.Delete(Path);
}
}
}

View file

@ -1,5 +1,6 @@
using System.Text.Json;
using System.Xml.Linq;
using System.Runtime.CompilerServices;
namespace AcDream.Runtime.Tests;
@ -59,7 +60,11 @@ public sealed class RuntimeDependencyBoundaryTests
.Descendants("ProjectReference")
.Select(reference => reference.Attribute("Include")?.Value)
.Where(static include => !string.IsNullOrWhiteSpace(include))
.Select(include => Path.GetFullPath(Path.Combine(projectDirectory, include!)))
.Select(include => Path.GetFullPath(Path.Combine(
projectDirectory,
include!.Replace(
'\\',
Path.DirectorySeparatorChar))))
.Order(StringComparer.OrdinalIgnoreCase)
.ToArray();
var expectedReferences = new[]
@ -94,21 +99,38 @@ public sealed class RuntimeDependencyBoundaryTests
Assert.DoesNotContain(loadedAssemblies, IsForbidden);
}
private static string FindRepositoryRoot()
private static string FindRepositoryRoot(
[CallerFilePath] string sourcePath = "")
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
string[] starts =
{
Path.GetDirectoryName(sourcePath) ?? string.Empty,
Directory.GetCurrentDirectory(),
AppContext.BaseDirectory,
};
foreach (string start in starts)
{
if (string.IsNullOrEmpty(start))
{
continue;
}
var directory = new DirectoryInfo(start);
while (directory is not null)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
if (File.Exists(Path.Combine(
directory.FullName,
"AcDream.slnx")))
{
return directory.FullName;
}
directory = directory.Parent;
}
}
throw new DirectoryNotFoundException(
$"Could not find AcDream.slnx above {AppContext.BaseDirectory}.");
"Could not find AcDream.slnx above the source, working, or output directory.");
}
private static string LibraryName(string libraryIdentity)