feat(extensions): session-bound process preparation and ProcessSpawnEvent

IExtensionContext.PrepareProcessAsync fills scope, session id and zone, runs the host policy, then ProcessSpawnEvent handlers (a throwing handler fails the spawn). IExtensionApi.Processes/Redactor are available during activation. ExecAsync spawns through the policy. Spec section 4 notes the default interface members and the fail-closed dispatch.
This commit is contained in:
EugeneTes
2026-09-26 19:15:26 +02:00
parent 52439b0dc4
commit 187414a459
12 changed files with 257 additions and 15 deletions

View File

@@ -199,6 +199,11 @@ zone comes from `AgentSessionOptions.TimeZone`, scheduler spec §8), calls `Proc
`ProcessSpawnEvent` to extensions in load order. Each handler may replace `Context`. There's no `Block`: refusing a
command is `ToolCallEvent.Block`'s job, before any process exists.
`IExtensionApi.Processes`, `IExtensionApi.Redactor` and `IExtensionContext.PrepareProcessAsync` are default interface
members that throw `NotSupportedException`, so implementations outside the core (test fakes, other front ends) keep
compiling; the core's `ExtensionApi` and `ExtensionContext` implement them. A `ProcessSpawnEvent` handler that throws is
reported as `extension_error` and the exception propagates out of `PrepareProcessAsync`, failing the spawn.
The session time zone members it reads (`AgentSessionOptions.TimeZone`, `AgenticCoreOptions.DefaultTimeZone`,
`AgentSession.TimeZone`, `IExtensionContext.TimeZone`) are introduced with this spec, ahead of the rest of scheduler
spec §8. `IExtensionContext.TimeZone` is a default interface member returning `TimeZoneInfo.Local`, so other

View File

@@ -2,6 +2,7 @@ using System.Text.Json;
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Tools;
@@ -55,6 +56,10 @@ public interface IExtensionApi
string ExtensionName { get; }
ILogger Logger { get; }
IEventBus Events { get; }
/// <summary>The host's process policy (host hooks only, no extension events). Available during activation.</summary>
IProcessSpawnPolicy Processes => throw new NotSupportedException("This extension API has no process policy.");
/// <summary>Replaces the host's known secrets with <c>[REDACTED]</c>. Available during activation.</summary>
ISecretRedactor Redactor => throw new NotSupportedException("This extension API has no secret redactor.");
// Registration (allowed during activation).
IDisposable On<TEvent>(ExtensionHandler<TEvent> handler) where TEvent : ExtensionEvent;

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Ui;
@@ -38,6 +39,14 @@ public interface IExtensionContext
string GetSystemPrompt();
/// <summary>Request compaction. Runs after the current turn when streaming, immediately when idle.</summary>
void Compact(CompactOptions? options = null);
/// <summary>
/// Prepares a child process for this session: fills scope, session id and (unless given) the session's zone, runs the
/// host policy, then <see cref="ProcessSpawnEvent"/> handlers in load order. Throws when a hook or handler throws.
/// Contexts outside the core runtime don't support it.
/// </summary>
ValueTask<ProcessSpawnContext> PrepareProcessAsync(ProcessSpawnRequest request, CancellationToken cancellationToken) =>
throw new NotSupportedException("This extension context can't prepare processes.");
}
/// <summary>Command handlers get these extra operations; they would deadlock if called from event handlers.</summary>

View File

@@ -13,6 +13,7 @@ namespace Volcanic.AgenticCore.Features.Agent;
/// <summary>What extensions see through their context and API actions.</summary>
public sealed partial class AgentSession : IExtensionHost
{
string IExtensionHost.Scope => _session.Scope;
IUserInterface IExtensionHost.Ui => _ui;
IReadOnlySessionManager IExtensionHost.SessionManager => _session;
IModelRegistry IExtensionHost.ModelRegistry => _models;

View File

@@ -33,9 +33,12 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
private readonly ISessionStore _defaultStore;
private readonly VolcanicPaths _paths;
private readonly ISecretRedactor _redactor;
private readonly IProcessSpawnPolicy _processes;
public AgentSessionFactory(IServiceProvider services, IOptions<AgenticCoreOptions> options, ILoggerFactory loggerFactory, ISecretRedactor redactor)
public AgentSessionFactory(IServiceProvider services, IOptions<AgenticCoreOptions> options, ILoggerFactory loggerFactory,
ISecretRedactor redactor, IProcessSpawnPolicy processes)
{
_processes = processes;
_services = services;
_options = options.Value;
_loggerFactory = loggerFactory;
@@ -68,7 +71,7 @@ public sealed class AgentSessionFactory : IAgentSessionFactory
}
var commands = new CommandRegistry();
var models = new ModelRegistry(new ApiKeyResolver(_paths.AuthFile));
var runner = new ExtensionRunner(tools, commands, models, _loggerFactory);
var runner = new ExtensionRunner(tools, commands, models, _loggerFactory, _processes, _redactor);
var registrations = new List<ExtensionRegistration>(_options.Extensions);
if (options.ExtraExtensions is not null) registrations.AddRange(options.ExtraExtensions);

View File

@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Messages;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Tools;
@@ -27,6 +28,8 @@ internal sealed class ExtensionApi : IExtensionApi
public string ExtensionName { get; }
public ILogger Logger { get; }
public IEventBus Events => _runner.EventBus;
public IProcessSpawnPolicy Processes => _runner.Processes;
public ISecretRedactor Redactor => _runner.Redactor;
internal void Commit()
{
@@ -100,17 +103,26 @@ internal sealed class ExtensionApi : IExtensionApi
public async Task<ExecResult> ExecAsync(string command, IReadOnlyList<string> args, ExecOptions? options = null, CancellationToken cancellationToken = default)
{
var host = Host(nameof(ExecAsync));
var psi = new ProcessStartInfo(command)
var spawn = await _runner.PrepareProcessAsync(new ProcessSpawnRequest
{
WorkingDirectory = options?.WorkingDirectory ?? host.Cwd,
Purpose = ProcessPurpose.ExtensionExec,
Command = command,
Arguments = args,
Cwd = options?.WorkingDirectory ?? host.Cwd,
Environment = options?.Environment,
Origin = ExtensionName,
}, cancellationToken).ConfigureAwait(false);
var psi = new ProcessStartInfo(spawn.Command)
{
WorkingDirectory = spawn.Cwd,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
};
foreach (var a in args) psi.ArgumentList.Add(a);
if (options?.Environment is not null)
foreach (var (k, v) in options.Environment) { if (v is null) psi.Environment.Remove(k); else psi.Environment[k] = v; }
foreach (var a in spawn.Arguments) psi.ArgumentList.Add(a);
psi.Environment.Clear();
foreach (var (k, v) in spawn.Environment) psi.Environment[k] = v;
using var process = new Process { StartInfo = psi };
var stdout = new StringBuilder();

View File

@@ -1,12 +1,13 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Abstractions.Providers;
using Volcanic.AgenticCore.Abstractions.Sessions;
using Volcanic.AgenticCore.Abstractions.Ui;
namespace Volcanic.AgenticCore.Features.Extensions;
internal class ExtensionContext(IExtensionHost host, Func<bool> isStale, ILogger logger) : IExtensionContext
internal class ExtensionContext(ExtensionRunner runner, IExtensionHost host, Func<bool> isStale, ILogger logger) : IExtensionContext
{
protected IExtensionHost Host => isStale() ? throw new StaleExtensionContextException() : host;
@@ -28,10 +29,16 @@ internal class ExtensionContext(IExtensionHost host, Func<bool> isStale, ILogger
public ContextUsage? GetContextUsage() => Host.GetContextUsage();
public string GetSystemPrompt() => Host.GetSystemPrompt();
public void Compact(CompactOptions? options = null) => Host.RequestCompaction(options);
public ValueTask<ProcessSpawnContext> PrepareProcessAsync(ProcessSpawnRequest request, CancellationToken cancellationToken)
{
_ = Host; // throws when this context is stale
return runner.PrepareProcessAsync(request, cancellationToken);
}
}
internal sealed class ExtensionCommandContext(IExtensionHost host, Func<bool> isStale, ILogger logger)
: ExtensionContext(host, isStale, logger), IExtensionCommandContext
internal sealed class ExtensionCommandContext(ExtensionRunner runner, IExtensionHost host, Func<bool> isStale, ILogger logger)
: ExtensionContext(runner, host, isStale, logger), IExtensionCommandContext
{
public Task WaitForIdleAsync(CancellationToken cancellationToken = default) => Host.WaitForIdleAsync(cancellationToken);
public Task<SwitchResult> NewSessionAsync(NewSessionOptions? options = null, CancellationToken cancellationToken = default) => Host.NewSessionAsync(options, cancellationToken);

View File

@@ -1,10 +1,12 @@
using Microsoft.Extensions.Logging;
using Volcanic.AgenticCore.Abstractions.Extensions;
using Volcanic.AgenticCore.Abstractions.Extensions.Events;
using Volcanic.AgenticCore.Abstractions.Processes;
using Volcanic.AgenticCore.Abstractions.Tools;
using Volcanic.AgenticCore.Features.Commands;
using Volcanic.AgenticCore.Features.Models;
using Volcanic.AgenticCore.Features.Tools;
using Volcanic.AgenticCore.Hosting;
namespace Volcanic.AgenticCore.Features.Extensions;
@@ -26,11 +28,14 @@ public sealed class ExtensionRunner
private readonly ILogger _logger;
private bool _stale;
public ExtensionRunner(ToolRegistry tools, CommandRegistry commands, ModelRegistry models, ILoggerFactory loggerFactory)
public ExtensionRunner(ToolRegistry tools, CommandRegistry commands, ModelRegistry models, ILoggerFactory loggerFactory,
IProcessSpawnPolicy processes, ISecretRedactor redactor)
{
Tools = tools;
Commands = commands;
Models = models;
Processes = processes;
Redactor = redactor;
_loggerFactory = loggerFactory;
_logger = loggerFactory.CreateLogger<ExtensionRunner>();
EventBus = new EventBus(_logger);
@@ -40,6 +45,10 @@ public sealed class ExtensionRunner
public CommandRegistry Commands { get; }
public ModelRegistry Models { get; }
public EventBus EventBus { get; }
/// <summary>The host's process policy (host hooks only). Session-bound spawns go through <see cref="PrepareProcessAsync"/>.</summary>
public IProcessSpawnPolicy Processes { get; }
/// <summary>Replaces the host's known secret values with <c>[REDACTED]</c>.</summary>
public ISecretRedactor Redactor { get; }
internal IExtensionHost? Host { get; private set; }
public IReadOnlyList<ExtensionLoadError> LoadErrors => _loadErrors;
public IReadOnlyList<string> LoadedExtensions => _extensions.Select(e => e.Name).ToList();
@@ -107,14 +116,34 @@ public sealed class ExtensionRunner
}
public IExtensionContext CreateContext() =>
new ExtensionContext(Host ?? throw new InvalidOperationException("Runner is not bound."), () => _stale, _logger);
new ExtensionContext(this, Host ?? throw new InvalidOperationException("Runner is not bound."), () => _stale, _logger);
public IExtensionCommandContext CreateCommandContext() =>
new ExtensionCommandContext(Host ?? throw new InvalidOperationException("Runner is not bound."), () => _stale, _logger);
new ExtensionCommandContext(this, Host ?? throw new InvalidOperationException("Runner is not bound."), () => _stale, _logger);
/// <summary>
/// Session-bound spawn preparation (spec section 4): fill scope, session id and zone from the session, run the policy,
/// then <see cref="ProcessSpawnEvent"/> handlers in load order. A throwing hook or handler fails the spawn.
/// </summary>
internal async ValueTask<ProcessSpawnContext> PrepareProcessAsync(ProcessSpawnRequest request, CancellationToken cancellationToken)
{
AssertActive();
var host = Host ?? throw new InvalidOperationException("Runner is not bound.");
var filled = request with
{
Scope = host.Scope,
SessionId = host.SessionId,
TimeZoneId = request.TimeZoneId ?? TimeZoneIds.ToIana(host.TimeZone),
};
var context = Processes.Prepare(filled);
var e = await EmitAsync(new ProcessSpawnEvent(context), cancellationToken).ConfigureAwait(false);
return e.Context;
}
/// <summary>
/// Dispatch an event to every handler registered for its type, in load order. Handler exceptions are reported through
/// <see cref="OnError"/> and dispatch continues, except for <see cref="ToolCallEvent"/> where an exception blocks the tool.
/// <see cref="OnError"/> and dispatch continues, except for <see cref="ToolCallEvent"/> where an exception blocks the tool
/// and <see cref="ProcessSpawnEvent"/> where it propagates to the caller (fail closed).
/// Dispatch stops early when the event reports <see cref="ExtensionEvent.ShortCircuit"/>.
/// </summary>
public async ValueTask<TEvent> EmitAsync<TEvent>(TEvent e, CancellationToken cancellationToken = default) where TEvent : ExtensionEvent
@@ -138,6 +167,7 @@ public sealed class ExtensionRunner
{
Report(new ExtensionError(handler.ExtensionName, e.Name, ex));
if (e is ToolCallEvent toolCall) toolCall.Block($"Extension '{handler.ExtensionName}' failed: {ex.Message}");
if (e is ProcessSpawnEvent) throw;
}
if (e.ShortCircuit) break;
}

View File

@@ -12,6 +12,8 @@ public interface IExtensionHost
{
string Cwd { get; }
string SessionId { get; }
/// <summary>The session store scope, e.g. <c>telegram-42</c>.</summary>
string Scope { get; }
IUserInterface Ui { get; }
IReadOnlySessionManager SessionManager { get; }
IModelRegistry ModelRegistry { get; }

View File

@@ -9,6 +9,7 @@ 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.Tests.Support;
@@ -31,7 +32,8 @@ public sealed class DiscoverableExtension : IExtension
public class ExtensionRunnerTests
{
private static ExtensionRunner NewRunner() =>
new(new ToolRegistry(), new CommandRegistry(), new ModelRegistry(new ApiKeyResolver(null, _ => null)), NullLoggerFactory.Instance);
new(new ToolRegistry(), new CommandRegistry(), new ModelRegistry(new ApiKeyResolver(null, _ => null)), NullLoggerFactory.Instance,
TestProcesses.Policy(), SecretRedactor.Empty);
private static ITool Tool(string name) => new DelegateTool<object>(name, "d", (_, _, _) => Task.FromResult(ToolResult.Text("ok")));

View File

@@ -0,0 +1,165 @@
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", TimeZone = TimeZoneInfo.FindSystemTimeZoneById("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 { TimeZone = TimeZoneInfo.FindSystemTimeZoneById("Asia/Tokyo") });
await runner.CreateContext().PrepareProcessAsync(Request("Europe/Berlin"), CancellationToken.None);
Assert.Equal("Europe/Berlin", Assert.Single(policy.Requests).TimeZoneId);
}
[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);
}
}

View File

@@ -14,6 +14,7 @@ public sealed class StubExtensionHost : IExtensionHost
public List<string> Calls { get; } = [];
public string Cwd { get; init; } = Path.GetTempPath();
public string SessionId => "session-1";
public string Scope { get; init; } = "test-scope";
public IUserInterface Ui { get; init; } = NullUserInterface.Instance;
public IReadOnlySessionManager SessionManager => throw new NotSupportedException();
public IModelRegistry ModelRegistry { get; init; } = new ModelRegistry(new ApiKeyResolver(null, _ => null));