forked from admin/VolcanicAgenticCore
- 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.
193 lines
8.9 KiB
C#
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);
|
|
}
|
|
}
|