fix(central-ui): resolve CentralUI-002/003/004 — site-scope enforcement, per-circuit console capture, cached auth state

This commit is contained in:
Joseph Doherty
2026-05-16 19:33:09 -04:00
parent 5a08b04535
commit 87f14c190a
17 changed files with 693 additions and 40 deletions

View File

@@ -465,4 +465,89 @@ public class ScriptAnalysisServiceTests
Assert.True(result.Success);
Assert.Equal("42", result.ReturnValueJson);
}
[Fact]
public async Task RunInSandbox_CapturesConsoleOutput()
{
var result = await _svc.RunInSandboxAsync(
new SandboxRunRequest(
"System.Console.WriteLine(\"hello-sandbox\"); return 1;",
Parameters: null,
TimeoutSeconds: null),
CancellationToken.None);
Assert.True(result.Success);
Assert.Contains("hello-sandbox", result.ConsoleOutput);
}
[Fact]
public async Task RunInSandbox_ConcurrentRuns_DoNotCrossContaminateConsoleOutput()
{
// Regression test for CentralUI-003. RunInSandboxAsync used to redirect the
// process-global Console.Out/Error to a per-call StringWriter. While one run
// is mid-flight, any concurrent run's `finally` restores Console.Out to the
// ORIGINAL writer — so the long run loses every Console.WriteLine it makes
// after that point, and short runs cross-contaminate each other. The fix
// routes capture per-call via an AsyncLocal writer without mutating
// process-global Console state.
// A long-running script: writes its tag, then burns CPU, then writes again,
// repeatedly. While it spins, many short runs start and finish around it.
async Task<string> RunLong()
{
var code = @"
for (int i = 0; i < 40; i++)
{
System.Console.WriteLine(""LONG"");
long acc = 0;
for (long j = 0; j < 2_000_000; j++) acc += j;
System.Console.WriteLine(""LONG"" + acc);
}
return 0;";
var r = await _svc.RunInSandboxAsync(
new SandboxRunRequest(code, Parameters: null, TimeoutSeconds: 30),
CancellationToken.None);
Assert.True(r.Success, r.Error);
return r.ConsoleOutput;
}
async Task<string> RunShort(int id)
{
var code = $"for (int i = 0; i < 30; i++) System.Console.WriteLine(\"S{id}\"); return 0;";
var r = await _svc.RunInSandboxAsync(
new SandboxRunRequest(code, Parameters: null, TimeoutSeconds: 30),
CancellationToken.None);
Assert.True(r.Success, r.Error);
return r.ConsoleOutput;
}
var longTask = RunLong();
var shortTasks = new List<Task<string>>();
for (var round = 0; round < 12; round++)
{
for (var k = 0; k < 4; k++)
shortTasks.Add(RunShort(round * 4 + k));
await Task.Yield();
}
var longOut = await longTask;
var shortOuts = await Task.WhenAll(shortTasks);
// The long run must have captured ALL 80 of its own writes (40 plain + 40 acc).
var longLines = longOut.Split('\n', StringSplitOptions.RemoveEmptyEntries)
.Count(l => l.StartsWith("LONG"));
Assert.Equal(80, longLines);
// No short run's output must have leaked into the long run's capture.
for (var i = 0; i < shortOuts.Length; i++)
Assert.DoesNotContain($"S{i}", longOut);
// Each short run captured exactly its own 30 lines and nothing else.
for (var i = 0; i < shortOuts.Length; i++)
{
var lines = shortOuts[i].Split('\n', StringSplitOptions.RemoveEmptyEntries);
Assert.Equal(30, lines.Length);
Assert.All(lines, l => Assert.Equal($"S{i}", l.Trim()));
}
}
}