Files
Playa/tests/Volcanic.AgenticCore.Tests/Processes/ProcessSessionBindingTests.cs
EugeneTes 76bda6cf55 fix(processes): address review findings
- Fill TimeZoneId only for a configured session/host zone with a resolvable IANA id
  (IExtensionHost.TimeZoneId); otherwise children inherit the host's TZ instead of TZ=Local/UTC.
- Host hardening logs a warning instead of throwing when libc/prctl can't be resolved.
- A hook or process_spawn handler returning a null or incomplete context fails the spawn with
  InvalidOperationException, so bash reports "process environment hook failed".
- Move tool-result redaction into the Agent slice with a behavioural no-change check.
- ProcessSpawnContext.ApplyTo(psi) sets cwd and replaces the environment; used by bash and ExecAsync.
- Split the Telegram process environment types into their own files; move the Extensions.Tests
  NoHostHardening under Support/; check the Claude Code assembly never references the spawn policy.
- Spec and docs: TZ inheritance, incomplete contexts, ApplyTo, async spawn handlers, root skip.
2026-09-26 19:32:47 +02:00

193 lines
8.9 KiB
C#

using Microsoft.Extensions.Logging.Abstractions;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Features.Commands;
using Volcanic.AgenticCore.Features.Extensions;
using Volcanic.AgenticCore.Features.Models;
using Volcanic.AgenticCore.Features.Processes;
using Volcanic.AgenticCore.Features.Tools;
using Volcanic.AgenticCore.Testing;
using Volcanic.AgenticCore.Tests.Features;
using Volcanic.AgenticCore.Tests.Support;
namespace Volcanic.AgenticCore.Tests.Processes;
public class ProcessSessionBindingTests
{
private sealed class RecordingPolicy(IProcessSpawnPolicy inner) : IProcessSpawnPolicy
{
public List<ProcessSpawnRequest> Requests { get; } = [];
public ProcessSpawnContext Prepare(ProcessSpawnRequest request) { Requests.Add(request); return inner.Prepare(request); }
}
private static ExtensionRunner Runner(IProcessSpawnPolicy policy, ISecretRedactor? redactor = null) =>
new(new ToolRegistry(), new CommandRegistry(), new ModelRegistry(new ApiKeyResolver(null, _ => null)), NullLoggerFactory.Instance,
policy, redactor ?? SecretRedactor.Empty);
private static ProcessSpawnRequest Request(string? tz = null) => new() { Purpose = ProcessPurpose.Other, Command = "x", Cwd = "/w", TimeZoneId = tz };
private static Dictionary<string, string> With(IReadOnlyDictionary<string, string> env, string name, string value) =>
new(env) { [name] = value };
[Fact]
public async Task PrepareProcessAsync_fills_scope_session_and_the_session_zone()
{
var policy = new RecordingPolicy(TestProcesses.Policy(new Dictionary<string, string?>()));
var runner = Runner(policy);
runner.Bind(new StubExtensionHost { Scope = "telegram-42", TimeZoneId = "Asia/Tokyo" });
var context = await runner.CreateContext().PrepareProcessAsync(Request(), CancellationToken.None);
var request = Assert.Single(policy.Requests);
Assert.Equal("telegram-42", request.Scope);
Assert.Equal("session-1", request.SessionId);
Assert.Equal("Asia/Tokyo", request.TimeZoneId);
Assert.Equal("telegram-42", context.Scope);
}
[Fact]
public async Task PrepareProcessAsync_keeps_an_explicit_time_zone()
{
var policy = new RecordingPolicy(TestProcesses.Policy(new Dictionary<string, string?>()));
var runner = Runner(policy);
runner.Bind(new StubExtensionHost { TimeZoneId = "Asia/Tokyo" });
await runner.CreateContext().PrepareProcessAsync(Request("Europe/Berlin"), CancellationToken.None);
Assert.Equal("Europe/Berlin", Assert.Single(policy.Requests).TimeZoneId);
}
[Fact]
public async Task PrepareProcessAsync_leaves_the_zone_unset_when_the_session_has_none_so_TZ_is_inherited()
{
var policy = new RecordingPolicy(TestProcesses.Policy(new Dictionary<string, string?>()));
var runner = Runner(policy);
runner.Bind(new StubExtensionHost { TimeZoneId = null });
await runner.CreateContext().PrepareProcessAsync(Request(), CancellationToken.None);
Assert.Null(Assert.Single(policy.Requests).TimeZoneId);
}
[Fact]
public async Task A_spawn_handler_that_drops_the_context_fails_the_spawn()
{
var runner = Runner(TestProcesses.Policy(new Dictionary<string, string?>()));
await runner.ActivateAsync(new LambdaExtension("bad", api =>
{
api.On<ProcessSpawnEvent>((e, _, _) => { e.Context = e.Context with { Environment = null! }; return ValueTask.CompletedTask; });
return ValueTask.CompletedTask;
}), "inline", CancellationToken.None);
runner.Bind(new StubExtensionHost());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => runner.CreateContext().PrepareProcessAsync(Request(), CancellationToken.None).AsTask());
Assert.Contains("process_spawn handler", ex.Message);
}
[Fact]
public async Task Spawn_handlers_run_in_load_order_after_the_host_hooks()
{
var runner = Runner(TestProcesses.Policy(new Dictionary<string, string?>(), o => o.Hooks.Add(c => c with { Environment = With(c.Environment, "ORDER", "host") })));
foreach (var name in new[] { "a", "b" })
await runner.ActivateAsync(new LambdaExtension(name, api =>
{
api.On<ProcessSpawnEvent>((e, _, _) =>
{
e.Context = e.Context with { Environment = With(e.Context.Environment, "ORDER", e.Context.Environment["ORDER"] + "," + name) };
return ValueTask.CompletedTask;
});
return ValueTask.CompletedTask;
}), "inline", CancellationToken.None);
runner.Bind(new StubExtensionHost());
var context = await runner.CreateContext().PrepareProcessAsync(Request(), CancellationToken.None);
Assert.Equal("host,a,b", context.Environment["ORDER"]);
}
[Fact]
public async Task A_throwing_spawn_handler_fails_the_spawn()
{
var runner = Runner(TestProcesses.Policy(new Dictionary<string, string?>()));
await runner.ActivateAsync(new LambdaExtension("bad", api =>
{
api.On<ProcessSpawnEvent>((_, _, _) => throw new InvalidOperationException("handler broke"));
return ValueTask.CompletedTask;
}), "inline", CancellationToken.None);
runner.Bind(new StubExtensionHost());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => runner.CreateContext().PrepareProcessAsync(Request(), CancellationToken.None).AsTask());
Assert.Equal("handler broke", ex.Message);
}
[Fact]
public async Task Api_processes_and_redactor_work_during_activation()
{
ProcessSpawnContext? prepared = null;
string? redacted = null;
var runner = Runner(TestProcesses.Policy(new Dictionary<string, string?> { ["OPENAI_API_KEY"] = "k", ["PATH"] = "/bin" }),
new SecretRedactor(["activation-secret"]));
await runner.ActivateAsync(new LambdaExtension("early", api =>
{
prepared = api.Processes.Prepare(Request());
redacted = api.Redactor.Redact("x activation-secret");
return ValueTask.CompletedTask;
}), "inline", CancellationToken.None);
Assert.NotNull(prepared);
Assert.False(prepared!.Environment.ContainsKey("OPENAI_API_KEY"));
Assert.Equal("x [REDACTED]", redacted);
}
public sealed record ProbeArgs(string Value);
[Fact]
public async Task A_session_tool_reaches_the_host_hooks_with_the_session_scope_and_id()
{
ProcessSpawnContext? seen = null;
var probe = new DelegateTool<ProbeArgs>("probe", "p", async (_, inv, ct) =>
{
await inv.Context.PrepareProcessAsync(new ProcessSpawnRequest { Purpose = ProcessPurpose.Other, Command = "x", Cwd = inv.Context.Cwd }, ct);
return ToolResult.Text("ok");
});
await using var host = new TestHost(o => o.ProcessEnvironment.Hooks.Add(c => { seen = c; return c; }),
new LambdaExtension("tools", api => { api.RegisterTool(probe); return ValueTask.CompletedTask; }));
host.Faux.Enqueue(FauxTurn.Tool("probe", new { value = "x" }), FauxTurn.Say("done"));
await using var session = await host.CreateSessionAsync();
await session.PromptAsync("go");
Assert.NotNull(seen);
Assert.Equal(session.SessionManager.Scope, seen!.Scope);
Assert.Equal(session.SessionId, seen.SessionId);
}
[Fact]
public async Task ExecAsync_environment_sets_and_removes_on_the_filtered_base()
{
var env = TestProcesses.ShellEnvironment(("DROP_ME", "1"), ("KEEP_ME", "1"), ("OPENAI_API_KEY", "sk-test-openai-key"));
var policy = new RecordingPolicy(TestProcesses.Policy(env));
var runner = Runner(policy);
IExtensionApi? api = null;
await runner.ActivateAsync(new LambdaExtension("execer", a => { api = a; return ValueTask.CompletedTask; }), "inline", CancellationToken.None);
runner.Bind(new StubExtensionHost());
var (command, args) = OperatingSystem.IsWindows() ? ("cmd.exe", new[] { "/d", "/c", "set" }) : ("sh", new[] { "-c", "env" });
var result = await api!.ExecAsync(command, args, new ExecOptions(Environment: new Dictionary<string, string?>
{
["ADDED_BY_CALLER"] = "yes", ["DROP_ME"] = null, ["GITHUB_TOKEN"] = "explicit-token",
}));
Assert.Equal(0, result.ExitCode);
Assert.Contains("ADDED_BY_CALLER=yes", result.Stdout);
Assert.Contains("GITHUB_TOKEN=explicit-token", result.Stdout);
Assert.Contains("KEEP_ME=1", result.Stdout);
Assert.DoesNotContain("DROP_ME", result.Stdout);
Assert.DoesNotContain("OPENAI_API_KEY", result.Stdout);
var request = Assert.Single(policy.Requests);
Assert.Equal(ProcessPurpose.ExtensionExec, request.Purpose);
Assert.Equal("execer", request.Origin);
}
}