-
Notifications
You must be signed in to change notification settings - Fork 255
Expand file tree
/
Copy pathProgram.cs
More file actions
157 lines (145 loc) · 7.19 KB
/
Copy pathProgram.cs
File metadata and controls
157 lines (145 loc) · 7.19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
using System.Text.Json;
using System.Text.Json.Serialization;
using CodeIsland.Core;
using CodeIsland.Ipc;
using CodeIsland.Protocol;
var mode = args.Length > 0 ? args[0].ToLowerInvariant() : "serve";
if (mode == "serve")
{
var machine = new SessionStateMachine();
await using var server = CreateServer(machine);
Console.WriteLine($"CodeIsland Bridge listening on {PipeEndpoint.Name()}");
await server.RunAsync();
}
else if (mode == "send" && args.Length >= 2)
{
var stdin = args.Contains("--stdin", StringComparer.OrdinalIgnoreCase);
var source = GetOption(args, "--source");
var eventName = GetOption(args, "--event");
var userSid = GetOption(args, "--user-sid");
var file = stdin ? null : args.Skip(1).FirstOrDefault(value => !value.StartsWith("--", StringComparison.Ordinal));
var input = stdin ? await Console.In.ReadToEndAsync() : await File.ReadAllTextAsync(file
?? throw new ArgumentException("send requires --stdin or an event JSON file."));
var agentEvent = ParseEvent(input, source, eventName);
await using var client = new PipeClient(userSid);
await client.ConnectWithRetryAsync(3, TimeSpan.FromSeconds(1), TimeSpan.FromMilliseconds(150));
var response = await client.SendAsync(
new PipeMessage(PipeMessageType.Event, Guid.NewGuid().ToString("N"), Event: agentEvent),
agentEvent.Type is AgentEventType.PermissionRequest or AgentEventType.Question
? TimeSpan.FromHours(8)
: TimeSpan.FromSeconds(3));
Console.WriteLine(HookResponse(response, agentEvent, source));
}
else if (mode == "self-test")
{
var permissionEvent = new AgentEvent("permission-1", "session-1", AgentKind.Codex,
AgentEventType.PermissionRequest, DateTimeOffset.UtcNow);
foreach (var (action, behavior) in new[]
{
(UserAction.Approve, "allow"),
(UserAction.AlwaysAllow, "always"),
(UserAction.Deny, "deny")
})
{
var hookResponse = HookResponse(new PipeMessage(PipeMessageType.ActionResponse, "response-1",
AckFor: permissionEvent.EventId, Action: action), permissionEvent, "codex");
using var hookDocument = JsonDocument.Parse(hookResponse);
var actual = hookDocument.RootElement.GetProperty("hookSpecificOutput").GetProperty("decision")
.GetProperty("behavior").GetString();
if (actual != behavior) throw new InvalidOperationException($"Expected Codex behavior {behavior}, got {actual}.");
}
using var stop = new CancellationTokenSource();
var machine = new SessionStateMachine();
await using var server = CreateServer(machine);
var serverTask = server.RunAsync(stop.Token);
await using var client = new PipeClient();
await client.ConnectWithRetryAsync(10, TimeSpan.FromMilliseconds(250), TimeSpan.FromMilliseconds(50));
await ExpectAck(client, new PipeMessage(PipeMessageType.Hello, "hello-1"));
var testEvent = new AgentEvent("event-1", "session-1", AgentKind.Codex,
AgentEventType.SessionStart, DateTimeOffset.UtcNow, Environment.CurrentDirectory, "IPC self-test");
var serializedEvent = JsonSerializer.Serialize(testEvent, CreateEventJsonOptions());
testEvent = ParseEvent(serializedEvent, "codex", "SessionStart");
var rawCodex = ParseEvent("""
{"session_id":"codex-native-1","cwd":"C:\\work","tool_name":"shell","message":"running"}
""", "codex", "PreToolUse");
if (rawCodex.Agent != AgentKind.Codex || rawCodex.Type != AgentEventType.ToolStart
|| rawCodex.SessionId != "codex-native-1" || rawCodex.ToolName != "shell")
throw new InvalidOperationException("Codex native hook payload normalization failed.");
await ExpectAck(client, new PipeMessage(PipeMessageType.Event, "message-1", Event: testEvent));
await ExpectAck(client, new PipeMessage(PipeMessageType.Heartbeat, "heartbeat-1"));
if (!machine.TryGet("session-1", out var snapshot) || snapshot?.State != SessionState.Running)
throw new InvalidOperationException("Event did not reach the session state machine.");
Console.WriteLine("SELF-TEST PASS: handshake, event acknowledgement, heartbeat and state update verified.");
await stop.CancelAsync();
await serverTask;
}
else
{
Console.Error.WriteLine("Usage: codeisland-bridge [serve | send <event.json> | send --stdin [--source codex] [--event SessionStart] [--user-sid SID] | self-test]");
return 2;
}
return 0;
static PipeServer CreateServer(SessionStateMachine machine) => new((message, _) =>
{
var response = message.Type switch
{
PipeMessageType.Hello or PipeMessageType.Heartbeat =>
new PipeMessage(PipeMessageType.Ack, Guid.NewGuid().ToString("N"), AckFor: message.MessageId),
PipeMessageType.Event when message.Event is not null =>
new PipeMessage(PipeMessageType.Ack, Guid.NewGuid().ToString("N"), AckFor: message.MessageId),
_ => new PipeMessage(PipeMessageType.Error, Guid.NewGuid().ToString("N"), Error: "Unsupported message.")
};
if (message.Type == PipeMessageType.Event && message.Event is not null) machine.Apply(message.Event);
return ValueTask.FromResult<PipeMessage?>(response);
});
static async Task ExpectAck(PipeClient client, PipeMessage message)
{
var response = await client.SendAsync(message, TimeSpan.FromSeconds(2));
if (response.Type != PipeMessageType.Ack || response.AckFor != message.MessageId)
throw new InvalidOperationException($"Expected ACK for {message.MessageId}.");
}
static AgentEvent ParseEvent(string json, string? source = null, string? eventName = null)
{
using var document = JsonDocument.Parse(json);
var root = document.RootElement;
if (root.ValueKind == JsonValueKind.Object
&& root.TryGetProperty("eventId", out _)
&& root.TryGetProperty("sessionId", out _)
&& root.TryGetProperty("type", out _))
{
return JsonSerializer.Deserialize<AgentEvent>(json, CreateEventJsonOptions())
?? throw new InvalidOperationException("Input event JSON is empty.");
}
return RawAgentEventNormalizer.Normalize(json, source, eventName);
}
static string? GetOption(string[] values, string name)
{
var index = Array.FindIndex(values, value => value.Equals(name, StringComparison.OrdinalIgnoreCase));
return index >= 0 && index + 1 < values.Length ? values[index + 1] : null;
}
static JsonSerializerOptions CreateEventJsonOptions() => new(JsonSerializerDefaults.Web)
{
PropertyNameCaseInsensitive = true,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
};
static string HookResponse(PipeMessage response, AgentEvent agentEvent, string? source)
{
if (agentEvent.Type != AgentEventType.PermissionRequest
|| !string.Equals(source, "codex", StringComparison.OrdinalIgnoreCase)
|| response.Type != PipeMessageType.ActionResponse)
return PipeJson.Serialize(response);
var behavior = response.Action switch
{
UserAction.Approve => "allow",
UserAction.AlwaysAllow => "always",
_ => "deny"
};
return JsonSerializer.Serialize(new
{
hookSpecificOutput = new
{
hookEventName = "PermissionRequest",
decision = new { behavior }
}
});
}