mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 12:18:44 +02:00
feat: support project hook commands
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aryx.AgentHost.Contracts;
|
||||
|
||||
internal static class HookTypeNames
|
||||
{
|
||||
public const string SessionStart = "sessionStart";
|
||||
public const string SessionEnd = "sessionEnd";
|
||||
public const string UserPromptSubmitted = "userPromptSubmitted";
|
||||
public const string PreToolUse = "preToolUse";
|
||||
public const string PostToolUse = "postToolUse";
|
||||
public const string ErrorOccurred = "errorOccurred";
|
||||
}
|
||||
|
||||
internal sealed class HookConfigFile
|
||||
{
|
||||
public int Version { get; init; }
|
||||
public HookConfigHooks Hooks { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class HookConfigHooks
|
||||
{
|
||||
public IReadOnlyList<HookCommandDefinition>? SessionStart { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? SessionEnd { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? UserPromptSubmitted { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? PreToolUse { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? PostToolUse { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? ErrorOccurred { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class HookCommandDefinition
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string? Bash { get; init; }
|
||||
|
||||
[JsonPropertyName("powershell")]
|
||||
public string? PowerShell { get; init; }
|
||||
|
||||
public string? Cwd { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Env { get; init; }
|
||||
public int? TimeoutSec { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class ResolvedHookSet
|
||||
{
|
||||
public static ResolvedHookSet Empty { get; } = new();
|
||||
|
||||
public IReadOnlyList<HookCommandDefinition> SessionStart { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> SessionEnd { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> UserPromptSubmitted { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> PreToolUse { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> PostToolUse { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> ErrorOccurred { get; init; } = [];
|
||||
|
||||
public bool IsEmpty =>
|
||||
SessionStart.Count == 0
|
||||
&& SessionEnd.Count == 0
|
||||
&& UserPromptSubmitted.Count == 0
|
||||
&& PreToolUse.Count == 0
|
||||
&& PostToolUse.Count == 0
|
||||
&& ErrorOccurred.Count == 0;
|
||||
}
|
||||
@@ -30,6 +30,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
List<IAsyncDisposable> disposables = [];
|
||||
List<AIAgent> agents = [];
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
||||
ResolvedHookSet configuredHooks = await HookConfigLoader.LoadAsync(command.ProjectPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
IHookCommandRunner hookCommandRunner = HookCommandRunner.Instance;
|
||||
SessionToolingBundle? toolingBundle = command.Tooling is null
|
||||
? null
|
||||
: await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken)
|
||||
@@ -51,7 +54,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
agentIndex,
|
||||
(request, invocation) => onPermissionRequest(definition, request, invocation),
|
||||
(request, invocation) => onUserInputRequest(definition, request, invocation),
|
||||
evt => onSessionEvent?.Invoke(definition, evt));
|
||||
evt => onSessionEvent?.Invoke(definition, evt),
|
||||
configuredHooks,
|
||||
hookCommandRunner);
|
||||
|
||||
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
|
||||
|
||||
@@ -78,7 +83,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
int agentIndex,
|
||||
PermissionRequestHandler? onPermissionRequest = null,
|
||||
UserInputHandler? onUserInputRequest = null,
|
||||
SessionEventHandler? onSessionEvent = null)
|
||||
SessionEventHandler? onSessionEvent = null,
|
||||
ResolvedHookSet? configuredHooks = null,
|
||||
IHookCommandRunner? hookCommandRunner = null)
|
||||
{
|
||||
// Let the Copilot SDK allocate session IDs. Explicit custom SessionId values currently
|
||||
// cause turns to complete without assistant output, even for simple single-agent prompts.
|
||||
@@ -98,7 +105,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
WorkingDirectory = command.ProjectPath,
|
||||
OnPermissionRequest = onPermissionRequest,
|
||||
OnUserInputRequest = onUserInputRequest,
|
||||
Hooks = CopilotSessionHooks.Create(command, definition),
|
||||
Hooks = CopilotSessionHooks.Create(command, definition, configuredHooks, hookCommandRunner),
|
||||
OnEvent = onSessionEvent,
|
||||
Streaming = true,
|
||||
CustomAgents = CreateCustomAgents(definition.Copilot?.CustomAgents),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
@@ -7,25 +9,209 @@ internal static class CopilotSessionHooks
|
||||
{
|
||||
private const string AllowDecision = "allow";
|
||||
private const string AskDecision = "ask";
|
||||
private const string DenyDecision = "deny";
|
||||
private static readonly JsonSerializerOptions HookJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public static SessionHooks Create(RunTurnCommandDto command, PatternAgentDefinitionDto agentDefinition)
|
||||
public static SessionHooks Create(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agentDefinition,
|
||||
ResolvedHookSet? configuredHooks = null,
|
||||
IHookCommandRunner? hookCommandRunner = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agentDefinition);
|
||||
ResolvedHookSet hooks = configuredHooks ?? ResolvedHookSet.Empty;
|
||||
IHookCommandRunner runner = hookCommandRunner ?? HookCommandRunner.Instance;
|
||||
|
||||
return new SessionHooks
|
||||
{
|
||||
OnPreToolUse = (input, _) => Task.FromResult<PreToolUseHookOutput?>(
|
||||
CreatePreToolUseOutput(command, agentDefinition, input)),
|
||||
OnPostToolUse = static (_, _) => Task.FromResult<PostToolUseHookOutput?>(null),
|
||||
OnUserPromptSubmitted = static (_, _) => Task.FromResult<UserPromptSubmittedHookOutput?>(null),
|
||||
OnSessionStart = static (_, _) => Task.FromResult<SessionStartHookOutput?>(null),
|
||||
OnSessionEnd = static (_, _) => Task.FromResult<SessionEndHookOutput?>(null),
|
||||
OnErrorOccurred = static (_, _) => Task.FromResult<ErrorOccurredHookOutput?>(null),
|
||||
OnPreToolUse = (input, _) => CreatePreToolUseOutputAsync(command, agentDefinition, hooks, runner, input),
|
||||
OnPostToolUse = (input, _) => RunPostToolUseHooksAsync(command, hooks, runner, input),
|
||||
OnUserPromptSubmitted = (input, _) => RunUserPromptSubmittedHooksAsync(command, hooks, runner, input),
|
||||
OnSessionStart = (input, _) => RunSessionStartHooksAsync(command, hooks, runner, input),
|
||||
OnSessionEnd = (input, _) => RunSessionEndHooksAsync(command, hooks, runner, input),
|
||||
OnErrorOccurred = (input, _) => RunErrorOccurredHooksAsync(command, hooks, runner, input),
|
||||
};
|
||||
}
|
||||
|
||||
private static PreToolUseHookOutput CreatePreToolUseOutput(
|
||||
private static async Task<PreToolUseHookOutput?> CreatePreToolUseOutputAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agentDefinition,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
PreToolUseHookInput input)
|
||||
{
|
||||
if (configuredHooks.PreToolUse.Count > 0)
|
||||
{
|
||||
string payload = SerializeHookInput(new FilePreToolUseHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
ToolName = input.ToolName,
|
||||
ToolArgs = SerializeHookValue(input.ToolArgs),
|
||||
});
|
||||
|
||||
foreach (HookCommandDefinition hook in configuredHooks.PreToolUse)
|
||||
{
|
||||
string? hookOutput = await hookCommandRunner.RunAsync(
|
||||
hook,
|
||||
payload,
|
||||
command.ProjectPath,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
PreToolUseHookOutput? decision = ParsePreToolUseDecision(hookOutput);
|
||||
if (string.Equals(decision?.PermissionDecision, DenyDecision, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CreateApprovalPolicyOutput(command, agentDefinition, input);
|
||||
}
|
||||
|
||||
private static async Task<PostToolUseHookOutput?> RunPostToolUseHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
PostToolUseHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.PostToolUse,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FilePostToolUseHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
ToolName = input.ToolName,
|
||||
ToolArgs = SerializeHookValue(input.ToolArgs),
|
||||
ToolResult = input.ToolResult,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<UserPromptSubmittedHookOutput?> RunUserPromptSubmittedHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
UserPromptSubmittedHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.UserPromptSubmitted,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileUserPromptSubmittedHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Prompt = input.Prompt,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<SessionStartHookOutput?> RunSessionStartHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
SessionStartHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.SessionStart,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileSessionStartHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Source = input.Source,
|
||||
InitialPrompt = input.InitialPrompt,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<SessionEndHookOutput?> RunSessionEndHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
SessionEndHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.SessionEnd,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileSessionEndHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Reason = input.Reason,
|
||||
FinalMessage = input.FinalMessage,
|
||||
Error = input.Error,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<ErrorOccurredHookOutput?> RunErrorOccurredHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
ErrorOccurredHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.ErrorOccurred,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileErrorOccurredHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Error = new FileHookError
|
||||
{
|
||||
Message = input.Error,
|
||||
Context = input.ErrorContext,
|
||||
Recoverable = input.Recoverable,
|
||||
},
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task RunConfiguredHooksAsync(
|
||||
IReadOnlyList<HookCommandDefinition> hooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
string projectPath,
|
||||
string payload)
|
||||
{
|
||||
if (hooks.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (HookCommandDefinition hook in hooks)
|
||||
{
|
||||
await hookCommandRunner.RunAsync(
|
||||
hook,
|
||||
payload,
|
||||
projectPath,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static PreToolUseHookOutput CreateApprovalPolicyOutput(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agentDefinition,
|
||||
PreToolUseHookInput input)
|
||||
@@ -42,6 +228,101 @@ internal static class CopilotSessionHooks
|
||||
};
|
||||
}
|
||||
|
||||
private static PreToolUseHookOutput? ParsePreToolUseDecision(string? hookOutput)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hookOutput))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
FilePreToolUseHookOutput? parsed = JsonSerializer.Deserialize<FilePreToolUseHookOutput>(hookOutput, HookJsonOptions);
|
||||
if (!string.Equals(parsed?.PermissionDecision, DenyDecision, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PreToolUseHookOutput
|
||||
{
|
||||
PermissionDecision = DenyDecision,
|
||||
PermissionDecisionReason = Normalize(parsed?.PermissionDecisionReason),
|
||||
};
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Ignoring invalid preToolUse hook output: {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeHookInput<T>(T input)
|
||||
=> JsonSerializer.Serialize(input, HookJsonOptions);
|
||||
|
||||
private static string SerializeHookValue(object? value)
|
||||
=> JsonSerializer.Serialize(value, HookJsonOptions);
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed class FileSessionStartHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string? InitialPrompt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FileSessionEndHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
public string? FinalMessage { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FileUserPromptSubmittedHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string Prompt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class FilePreToolUseHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string ToolName { get; init; } = string.Empty;
|
||||
public string ToolArgs { get; init; } = "null";
|
||||
}
|
||||
|
||||
private sealed class FilePostToolUseHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string ToolName { get; init; } = string.Empty;
|
||||
public string ToolArgs { get; init; } = "null";
|
||||
public object? ToolResult { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FileErrorOccurredHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public FileHookError Error { get; init; } = new();
|
||||
}
|
||||
|
||||
private sealed class FileHookError
|
||||
{
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public string Context { get; init; } = string.Empty;
|
||||
public bool Recoverable { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FilePreToolUseHookOutput
|
||||
{
|
||||
public string? PermissionDecision { get; init; }
|
||||
public string? PermissionDecisionReason { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal interface IHookCommandRunner
|
||||
{
|
||||
Task<string?> RunAsync(
|
||||
HookCommandDefinition hook,
|
||||
string inputJson,
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class HookCommandRunner : IHookCommandRunner
|
||||
{
|
||||
private const int DefaultTimeoutSeconds = 30;
|
||||
|
||||
public static HookCommandRunner Instance { get; } = new();
|
||||
|
||||
public async Task<string?> RunAsync(
|
||||
HookCommandDefinition hook,
|
||||
string inputJson,
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hook);
|
||||
ArgumentNullException.ThrowIfNull(inputJson);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
|
||||
|
||||
string? commandText = SelectCommandText(hook);
|
||||
if (commandText is null)
|
||||
{
|
||||
Console.Error.WriteLine("[aryx hooks] Skipping hook because no compatible shell command is configured for this platform.");
|
||||
return null;
|
||||
}
|
||||
|
||||
string workingDirectory = ResolveWorkingDirectory(projectPath, hook.Cwd);
|
||||
ProcessStartInfo startInfo = CreateStartInfo(commandText, workingDirectory);
|
||||
ApplyEnvironment(startInfo, hook.Env);
|
||||
|
||||
using Process process = new()
|
||||
{
|
||||
StartInfo = startInfo,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
if (!process.Start())
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}'.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch (Win32Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync();
|
||||
Task<string> stderrTask = process.StandardError.ReadToEndAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await process.StandardInput.WriteAsync(inputJson).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync().ConfigureAwait(false);
|
||||
process.StandardInput.Close();
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
TryKillProcess(process);
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to write hook input for '{commandText}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (ObjectDisposedException exception)
|
||||
{
|
||||
TryKillProcess(process);
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to write hook input for '{commandText}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
TimeSpan timeout = TimeSpan.FromSeconds(hook.TimeoutSec ?? DefaultTimeoutSeconds);
|
||||
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
TryKillProcess(process);
|
||||
await DrainOutputAsync(process, stdoutTask, stderrTask).ConfigureAwait(false);
|
||||
Console.Error.WriteLine($"[aryx hooks] Hook command timed out after {(int)timeout.TotalSeconds} seconds: '{commandText}'.");
|
||||
return null;
|
||||
}
|
||||
|
||||
string stdout = await stdoutTask.ConfigureAwait(false);
|
||||
string stderr = await stderrTask.ConfigureAwait(false);
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
string detail = string.IsNullOrWhiteSpace(stderr) ? $"exit code {process.ExitCode}" : stderr.Trim();
|
||||
Console.Error.WriteLine($"[aryx hooks] Hook command failed for '{commandText}': {detail}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
private static async Task DrainOutputAsync(Process process, Task<string> stdoutTask, Task<string> stderrTask)
|
||||
{
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Process already exited or could not be waited on.
|
||||
}
|
||||
|
||||
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateStartInfo(string commandText, string workingDirectory)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
WorkingDirectory = workingDirectory,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
startInfo.FileName = "powershell.exe";
|
||||
startInfo.ArgumentList.Add("-NoLogo");
|
||||
startInfo.ArgumentList.Add("-NoProfile");
|
||||
startInfo.ArgumentList.Add("-NonInteractive");
|
||||
startInfo.ArgumentList.Add("-ExecutionPolicy");
|
||||
startInfo.ArgumentList.Add("Bypass");
|
||||
startInfo.ArgumentList.Add("-Command");
|
||||
startInfo.ArgumentList.Add(commandText);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
startInfo.FileName = "bash";
|
||||
startInfo.ArgumentList.Add("-lc");
|
||||
startInfo.ArgumentList.Add(commandText);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static void ApplyEnvironment(ProcessStartInfo startInfo, IReadOnlyDictionary<string, string>? environment)
|
||||
{
|
||||
if (environment is not { Count: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ((string key, string value) in environment)
|
||||
{
|
||||
startInfo.Environment[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveWorkingDirectory(string projectPath, string? configuredCwd)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredCwd))
|
||||
{
|
||||
return Path.GetFullPath(projectPath);
|
||||
}
|
||||
|
||||
string resolved = Path.IsPathRooted(configuredCwd)
|
||||
? configuredCwd
|
||||
: Path.Combine(projectPath, configuredCwd);
|
||||
|
||||
return Path.GetFullPath(resolved);
|
||||
}
|
||||
|
||||
private static string? SelectCommandText(HookCommandDefinition hook)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return NormalizeOptionalString(hook.PowerShell);
|
||||
}
|
||||
|
||||
return NormalizeOptionalString(hook.Bash);
|
||||
}
|
||||
|
||||
private static void TryKillProcess(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Process already exited.
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// The platform does not support process tree termination.
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class HookConfigLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
public static async Task<ResolvedHookSet> LoadAsync(string projectPath, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
|
||||
|
||||
string hooksDirectory = Path.Combine(projectPath, ".github", "hooks");
|
||||
if (!Directory.Exists(hooksDirectory))
|
||||
{
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
|
||||
string[] hookFiles;
|
||||
try
|
||||
{
|
||||
hookFiles = Directory.GetFiles(hooksDirectory, "*.json", SearchOption.TopDirectoryOnly);
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to enumerate hook files in '{hooksDirectory}': {exception.Message}");
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to enumerate hook files in '{hooksDirectory}': {exception.Message}");
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
|
||||
if (hookFiles.Length == 0)
|
||||
{
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
|
||||
Array.Sort(hookFiles, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
List<HookCommandDefinition> sessionStart = [];
|
||||
List<HookCommandDefinition> sessionEnd = [];
|
||||
List<HookCommandDefinition> userPromptSubmitted = [];
|
||||
List<HookCommandDefinition> preToolUse = [];
|
||||
List<HookCommandDefinition> postToolUse = [];
|
||||
List<HookCommandDefinition> errorOccurred = [];
|
||||
|
||||
foreach (string hookFile in hookFiles)
|
||||
{
|
||||
HookConfigFile? config = await ReadHookConfigAsync(hookFile, cancellationToken).ConfigureAwait(false);
|
||||
if (config is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.Version != 1)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Skipping '{hookFile}' because it declares unsupported version '{config.Version}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
AddHooks(sessionStart, config.Hooks.SessionStart, HookTypeNames.SessionStart, hookFile);
|
||||
AddHooks(sessionEnd, config.Hooks.SessionEnd, HookTypeNames.SessionEnd, hookFile);
|
||||
AddHooks(userPromptSubmitted, config.Hooks.UserPromptSubmitted, HookTypeNames.UserPromptSubmitted, hookFile);
|
||||
AddHooks(preToolUse, config.Hooks.PreToolUse, HookTypeNames.PreToolUse, hookFile);
|
||||
AddHooks(postToolUse, config.Hooks.PostToolUse, HookTypeNames.PostToolUse, hookFile);
|
||||
AddHooks(errorOccurred, config.Hooks.ErrorOccurred, HookTypeNames.ErrorOccurred, hookFile);
|
||||
}
|
||||
|
||||
if (
|
||||
sessionStart.Count == 0
|
||||
&& sessionEnd.Count == 0
|
||||
&& userPromptSubmitted.Count == 0
|
||||
&& preToolUse.Count == 0
|
||||
&& postToolUse.Count == 0
|
||||
&& errorOccurred.Count == 0)
|
||||
{
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
|
||||
return new ResolvedHookSet
|
||||
{
|
||||
SessionStart = [.. sessionStart],
|
||||
SessionEnd = [.. sessionEnd],
|
||||
UserPromptSubmitted = [.. userPromptSubmitted],
|
||||
PreToolUse = [.. preToolUse],
|
||||
PostToolUse = [.. postToolUse],
|
||||
ErrorOccurred = [.. errorOccurred],
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddHooks(
|
||||
ICollection<HookCommandDefinition> target,
|
||||
IReadOnlyList<HookCommandDefinition>? definitions,
|
||||
string hookType,
|
||||
string hookFile)
|
||||
{
|
||||
if (definitions is not { Count: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (HookCommandDefinition definition in definitions)
|
||||
{
|
||||
HookCommandDefinition? normalized = NormalizeDefinition(definition, hookType, hookFile);
|
||||
if (normalized is not null)
|
||||
{
|
||||
target.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static HookCommandDefinition? NormalizeDefinition(
|
||||
HookCommandDefinition definition,
|
||||
string hookType,
|
||||
string hookFile)
|
||||
{
|
||||
string type = NormalizeOptionalString(definition.Type) ?? string.Empty;
|
||||
if (!string.Equals(type, "command", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Skipping '{hookType}' entry in '{hookFile}' because type '{definition.Type}' is unsupported.");
|
||||
return null;
|
||||
}
|
||||
|
||||
string? bash = NormalizeOptionalString(definition.Bash);
|
||||
string? powerShell = NormalizeOptionalString(definition.PowerShell);
|
||||
if (bash is null && powerShell is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Skipping '{hookType}' entry in '{hookFile}' because no shell command is configured.");
|
||||
return null;
|
||||
}
|
||||
|
||||
int? timeoutSec = definition.TimeoutSec;
|
||||
if (timeoutSec is <= 0)
|
||||
{
|
||||
timeoutSec = null;
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, string>? env = NormalizeEnvironment(definition.Env);
|
||||
|
||||
return new HookCommandDefinition
|
||||
{
|
||||
Type = "command",
|
||||
Bash = bash,
|
||||
PowerShell = powerShell,
|
||||
Cwd = NormalizeOptionalString(definition.Cwd),
|
||||
Env = env,
|
||||
TimeoutSec = timeoutSec,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<HookConfigFile?> ReadHookConfigAsync(string hookFile, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream stream = File.OpenRead(hookFile);
|
||||
return await JsonSerializer.DeserializeAsync<HookConfigFile>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to parse '{hookFile}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to read '{hookFile}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to read '{hookFile}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string>? NormalizeEnvironment(IReadOnlyDictionary<string, string>? environment)
|
||||
{
|
||||
if (environment is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, string> normalized = new(StringComparer.Ordinal);
|
||||
foreach ((string key, string value) in environment)
|
||||
{
|
||||
string? normalizedKey = NormalizeOptionalString(key);
|
||||
if (normalizedKey is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized[normalizedKey] = value;
|
||||
}
|
||||
|
||||
return normalized.Count == 0 ? null : normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotSessionHooksTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Create_FileBasedPreToolUseDenyOverridesApprovalPolicy()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
RecordingHookCommandRunner runner = new(
|
||||
[
|
||||
"""{"permissionDecision":"deny","permissionDecisionReason":"Blocked by repository hook"}""",
|
||||
]);
|
||||
ResolvedHookSet configuredHooks = new()
|
||||
{
|
||||
PreToolUse =
|
||||
[
|
||||
CreateHookCommand("deny-pre-tool"),
|
||||
],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
Timestamp = 1710000000000,
|
||||
Cwd = command.ProjectPath,
|
||||
ToolName = "view",
|
||||
ToolArgs = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
path = "README.md",
|
||||
}),
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("deny", decision?.PermissionDecision);
|
||||
Assert.Equal("Blocked by repository hook", decision?.PermissionDecisionReason);
|
||||
|
||||
RecordedHookInvocation invocation = Assert.Single(runner.Invocations);
|
||||
JsonDocument payload = JsonDocument.Parse(invocation.InputJson);
|
||||
Assert.Equal("view", payload.RootElement.GetProperty("toolName").GetString());
|
||||
Assert.Equal("{\"path\":\"README.md\"}", payload.RootElement.GetProperty("toolArgs").GetString());
|
||||
Assert.Equal(command.ProjectPath, invocation.ProjectPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PreToolUseFallsThroughWhenFileHooksDoNotDeny()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
RecordingHookCommandRunner runner = new(
|
||||
[
|
||||
"""{"permissionDecision":"allow"}""",
|
||||
]);
|
||||
ResolvedHookSet configuredHooks = new()
|
||||
{
|
||||
PreToolUse =
|
||||
[
|
||||
CreateHookCommand("allow-pre-tool"),
|
||||
],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "view",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("ask", decision?.PermissionDecision);
|
||||
Assert.Single(runner.Invocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PreToolUseIgnoresInvalidHookOutputAndFallsThrough()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
RecordingHookCommandRunner runner = new(
|
||||
[
|
||||
"not-json",
|
||||
]);
|
||||
ResolvedHookSet configuredHooks = new()
|
||||
{
|
||||
PreToolUse =
|
||||
[
|
||||
CreateHookCommand("invalid-pre-tool"),
|
||||
],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "view",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("ask", decision?.PermissionDecision);
|
||||
Assert.Single(runner.Invocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_RunsConfiguredNonPreToolHooks()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithoutApprovalRules();
|
||||
RecordingHookCommandRunner runner = new();
|
||||
ResolvedHookSet configuredHooks = new()
|
||||
{
|
||||
SessionStart = [CreateHookCommand("session-start-hook")],
|
||||
UserPromptSubmitted = [CreateHookCommand("prompt-hook")],
|
||||
PostToolUse = [CreateHookCommand("post-tool-hook")],
|
||||
SessionEnd = [CreateHookCommand("session-end-hook")],
|
||||
ErrorOccurred = [CreateHookCommand("error-hook")],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
||||
|
||||
await hooks.OnSessionStart!(
|
||||
new SessionStartHookInput
|
||||
{
|
||||
Timestamp = 1,
|
||||
Cwd = command.ProjectPath,
|
||||
Source = "new",
|
||||
InitialPrompt = "Create the feature",
|
||||
},
|
||||
null!);
|
||||
await hooks.OnUserPromptSubmitted!(
|
||||
new UserPromptSubmittedHookInput
|
||||
{
|
||||
Timestamp = 2,
|
||||
Cwd = command.ProjectPath,
|
||||
Prompt = "Refactor the API",
|
||||
},
|
||||
null!);
|
||||
await hooks.OnPostToolUse!(
|
||||
new PostToolUseHookInput
|
||||
{
|
||||
Timestamp = 3,
|
||||
Cwd = command.ProjectPath,
|
||||
ToolName = "view",
|
||||
ToolArgs = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
path = "README.md",
|
||||
}),
|
||||
ToolResult = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
resultType = "success",
|
||||
textResultForLlm = "Read 1 file",
|
||||
}),
|
||||
},
|
||||
null!);
|
||||
await hooks.OnSessionEnd!(
|
||||
new SessionEndHookInput
|
||||
{
|
||||
Timestamp = 4,
|
||||
Cwd = command.ProjectPath,
|
||||
Reason = "complete",
|
||||
FinalMessage = "Done",
|
||||
},
|
||||
null!);
|
||||
await hooks.OnErrorOccurred!(
|
||||
new ErrorOccurredHookInput
|
||||
{
|
||||
Timestamp = 5,
|
||||
Cwd = command.ProjectPath,
|
||||
Error = "Network timeout",
|
||||
ErrorContext = "tool_execution",
|
||||
Recoverable = true,
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal(
|
||||
["session-start-hook", "prompt-hook", "post-tool-hook", "session-end-hook", "error-hook"],
|
||||
runner.Invocations.Select(invocation => GetCommandText(invocation.Hook)).ToArray());
|
||||
|
||||
JsonDocument postToolPayload = JsonDocument.Parse(runner.Invocations[2].InputJson);
|
||||
Assert.Equal("view", postToolPayload.RootElement.GetProperty("toolName").GetString());
|
||||
Assert.Equal("success", postToolPayload.RootElement.GetProperty("toolResult").GetProperty("resultType").GetString());
|
||||
|
||||
JsonDocument errorPayload = JsonDocument.Parse(runner.Invocations[4].InputJson);
|
||||
Assert.Equal("Network timeout", errorPayload.RootElement.GetProperty("error").GetProperty("message").GetString());
|
||||
Assert.Equal("tool_execution", errorPayload.RootElement.GetProperty("error").GetProperty("context").GetString());
|
||||
Assert.True(errorPayload.RootElement.GetProperty("error").GetProperty("recoverable").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithoutConfiguredFileHooksPreservesExistingApprovalBehavior()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithoutApprovalRules();
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "view",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("allow", decision?.PermissionDecision);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommandWithToolApproval()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
ApprovalPolicy = new ApprovalPolicyDto
|
||||
{
|
||||
Rules =
|
||||
[
|
||||
new ApprovalCheckpointRuleDto
|
||||
{
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
},
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommandWithoutApprovalRules()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ProjectPath = command.ProjectPath,
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = command.Pattern.Id,
|
||||
Name = command.Pattern.Name,
|
||||
Mode = command.Pattern.Mode,
|
||||
Availability = command.Pattern.Availability,
|
||||
ApprovalPolicy = new ApprovalPolicyDto(),
|
||||
Agents = command.Pattern.Agents,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static HookCommandDefinition CreateHookCommand(string name)
|
||||
=> new()
|
||||
{
|
||||
Type = "command",
|
||||
Bash = name,
|
||||
PowerShell = name,
|
||||
};
|
||||
|
||||
private static string GetCommandText(HookCommandDefinition hook)
|
||||
=> hook.PowerShell ?? hook.Bash ?? string.Empty;
|
||||
|
||||
private sealed class RecordingHookCommandRunner : IHookCommandRunner
|
||||
{
|
||||
private readonly Queue<string?> _outputs;
|
||||
|
||||
public List<RecordedHookInvocation> Invocations { get; } = [];
|
||||
|
||||
public RecordingHookCommandRunner(IEnumerable<string?>? outputs = null)
|
||||
{
|
||||
_outputs = outputs is null ? new Queue<string?>() : new Queue<string?>(outputs);
|
||||
}
|
||||
|
||||
public Task<string?> RunAsync(
|
||||
HookCommandDefinition hook,
|
||||
string inputJson,
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Invocations.Add(new RecordedHookInvocation(hook, inputJson, projectPath));
|
||||
return Task.FromResult(_outputs.Count > 0 ? _outputs.Dequeue() : string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RecordedHookInvocation(
|
||||
HookCommandDefinition Hook,
|
||||
string InputJson,
|
||||
string ProjectPath);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class HookCommandRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_PipesJsonIntoHookStandardInput()
|
||||
{
|
||||
HookCommandRunner runner = new();
|
||||
using TestDirectory project = new();
|
||||
HookCommandDefinition hook = CreatePlatformHook(
|
||||
OperatingSystem.IsWindows()
|
||||
? "$payload = [Console]::In.ReadToEnd(); Write-Output $payload"
|
||||
: "payload=$(cat); printf '%s' \"$payload\"");
|
||||
|
||||
string input = """{"toolName":"view","toolArgs":"{\"path\":\"README.md\"}"}""";
|
||||
|
||||
string? output = await runner.RunAsync(hook, input, project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Equal(input, output?.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsNullWhenHookTimesOut()
|
||||
{
|
||||
HookCommandRunner runner = new();
|
||||
using TestDirectory project = new();
|
||||
HookCommandDefinition hook = CreatePlatformHook(
|
||||
OperatingSystem.IsWindows() ? "Start-Sleep -Seconds 5" : "sleep 5",
|
||||
timeoutSec: 1);
|
||||
|
||||
string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Null(output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsNullWhenHookFails()
|
||||
{
|
||||
HookCommandRunner runner = new();
|
||||
using TestDirectory project = new();
|
||||
HookCommandDefinition hook = CreatePlatformHook(
|
||||
OperatingSystem.IsWindows() ? "Write-Error 'boom'; exit 1" : "echo boom >&2; exit 1");
|
||||
|
||||
string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Null(output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsNullWhenCurrentPlatformCommandIsMissing()
|
||||
{
|
||||
HookCommandRunner runner = new();
|
||||
using TestDirectory project = new();
|
||||
HookCommandDefinition hook = OperatingSystem.IsWindows()
|
||||
? new HookCommandDefinition { Type = "command", Bash = "echo unsupported" }
|
||||
: new HookCommandDefinition { Type = "command", PowerShell = "Write-Output unsupported" };
|
||||
|
||||
string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Null(output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesConfiguredWorkingDirectoryAndEnvironment()
|
||||
{
|
||||
HookCommandRunner runner = new();
|
||||
using TestDirectory project = new();
|
||||
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, "scripts")).FullName;
|
||||
HookCommandDefinition hook = CreatePlatformHook(
|
||||
OperatingSystem.IsWindows()
|
||||
? "Write-Output ((Get-Location).Path + '|' + $env:HOOK_TEST_ENV)"
|
||||
: "printf '%s|%s' \"$(pwd)\" \"$HOOK_TEST_ENV\"",
|
||||
cwd: "scripts",
|
||||
env: new Dictionary<string, string>
|
||||
{
|
||||
["HOOK_TEST_ENV"] = "configured",
|
||||
});
|
||||
|
||||
string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Equal($"{hooksDirectory}|configured", output?.Trim());
|
||||
}
|
||||
|
||||
private static HookCommandDefinition CreatePlatformHook(
|
||||
string command,
|
||||
int? timeoutSec = null,
|
||||
string? cwd = null,
|
||||
IReadOnlyDictionary<string, string>? env = null)
|
||||
{
|
||||
return OperatingSystem.IsWindows()
|
||||
? new HookCommandDefinition
|
||||
{
|
||||
Type = "command",
|
||||
PowerShell = command,
|
||||
TimeoutSec = timeoutSec,
|
||||
Cwd = cwd,
|
||||
Env = env,
|
||||
}
|
||||
: new HookCommandDefinition
|
||||
{
|
||||
Type = "command",
|
||||
Bash = command,
|
||||
TimeoutSec = timeoutSec,
|
||||
Cwd = cwd,
|
||||
Env = env,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TestDirectory : IDisposable
|
||||
{
|
||||
private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("aryx-hooks-runner-");
|
||||
|
||||
public string Path => _directory.FullName;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_directory.Exists)
|
||||
{
|
||||
_directory.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class HookConfigLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesSupportedHookTypes()
|
||||
{
|
||||
using TestDirectory project = new();
|
||||
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, ".github", "hooks")).FullName;
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(hooksDirectory, "hooks.json"),
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"sessionStart": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo session-start",
|
||||
"powershell": "Write-Output session-start",
|
||||
"cwd": ".",
|
||||
"env": { "HOOK_MODE": "audit" },
|
||||
"timeoutSec": 15
|
||||
}
|
||||
],
|
||||
"preToolUse": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo pre-tool",
|
||||
"powershell": "Write-Output pre-tool"
|
||||
}
|
||||
],
|
||||
"errorOccurred": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo error-hook",
|
||||
"powershell": "Write-Output error-hook"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
|
||||
|
||||
HookCommandDefinition sessionStart = Assert.Single(hooks.SessionStart);
|
||||
Assert.Equal("command", sessionStart.Type);
|
||||
Assert.Equal("echo session-start", sessionStart.Bash);
|
||||
Assert.Equal("Write-Output session-start", sessionStart.PowerShell);
|
||||
Assert.Equal(".", sessionStart.Cwd);
|
||||
Assert.Equal(15, sessionStart.TimeoutSec);
|
||||
Assert.NotNull(sessionStart.Env);
|
||||
Assert.Equal("audit", sessionStart.Env["HOOK_MODE"]);
|
||||
Assert.Single(hooks.PreToolUse);
|
||||
Assert.Single(hooks.ErrorOccurred);
|
||||
Assert.False(hooks.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_MergesHookFilesInFileNameOrder()
|
||||
{
|
||||
using TestDirectory project = new();
|
||||
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, ".github", "hooks")).FullName;
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(hooksDirectory, "20-second.json"),
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"preToolUse": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo second",
|
||||
"powershell": "Write-Output second"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(hooksDirectory, "10-first.json"),
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"preToolUse": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo first",
|
||||
"powershell": "Write-Output first"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
|
||||
|
||||
string[] commands = hooks.PreToolUse.Select(GetCommandText).ToArray();
|
||||
Assert.Equal(2, commands.Length);
|
||||
Assert.Contains("first", commands[0], StringComparison.Ordinal);
|
||||
Assert.Contains("second", commands[1], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ReturnsEmptyWhenHooksDirectoryIsMissing()
|
||||
{
|
||||
using TestDirectory project = new();
|
||||
|
||||
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Same(ResolvedHookSet.Empty, hooks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SkipsInvalidFilesAndUnsupportedVersions()
|
||||
{
|
||||
using TestDirectory project = new();
|
||||
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, ".github", "hooks")).FullName;
|
||||
|
||||
await File.WriteAllTextAsync(Path.Combine(hooksDirectory, "00-invalid.json"), "{ not-json");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(hooksDirectory, "10-unsupported.json"),
|
||||
"""
|
||||
{
|
||||
"version": 2,
|
||||
"hooks": {
|
||||
"sessionStart": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo unsupported",
|
||||
"powershell": "Write-Output unsupported"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(hooksDirectory, "20-valid.json"),
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"sessionEnd": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo valid",
|
||||
"powershell": "Write-Output valid"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
|
||||
|
||||
HookCommandDefinition valid = Assert.Single(hooks.SessionEnd);
|
||||
Assert.Contains("valid", GetCommandText(valid), StringComparison.Ordinal);
|
||||
Assert.Empty(hooks.SessionStart);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ReturnsEmptyForEmptyHooksObject()
|
||||
{
|
||||
using TestDirectory project = new();
|
||||
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, ".github", "hooks")).FullName;
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(hooksDirectory, "hooks.json"),
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {}
|
||||
}
|
||||
""");
|
||||
|
||||
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Same(ResolvedHookSet.Empty, hooks);
|
||||
}
|
||||
|
||||
private static string GetCommandText(HookCommandDefinition hook)
|
||||
=> hook.PowerShell ?? hook.Bash ?? string.Empty;
|
||||
|
||||
private sealed class TestDirectory : IDisposable
|
||||
{
|
||||
private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("aryx-hooks-loader-");
|
||||
|
||||
public string Path => _directory.FullName;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_directory.Exists)
|
||||
{
|
||||
_directory.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user