Files
mim/src/protocol.zig
T
2026-06-21 12:31:27 +02:00

490 lines
25 KiB
Zig

const std = @import("std");
const context_mod = @import("context.zig");
const session_mod = @import("session.zig");
// Tiny local session protocol for trusted same-user tools.
// req: session/002, session/003, governance/003
test {
_ = handleLine;
}
pub const max_request_bytes = 4096;
pub fn replayToolLogLine(allocator: std.mem.Allocator, session: *session_mod.Session, log_line: []const u8) ![]u8 {
const parsed = parseToolLog(log_line) orelse return allocator.dupe(u8, "err invalid tool log\n");
if (!std.mem.eql(u8, parsed.status, "ok")) return allocator.dupe(u8, "err logged command failed\n");
return commandResponse(allocator, session, parsed.command);
}
pub fn handleLine(allocator: std.mem.Allocator, session: *session_mod.Session, line: []const u8) ![]u8 {
const trimmed = std.mem.trimEnd(u8, line, "\r\n");
if (std.mem.eql(u8, trimmed, "state")) return stateResponse(allocator, session);
if (std.mem.eql(u8, trimmed, "context")) return context_mod.sessionContextAlloc(allocator, session, null, null, .{});
if (std.mem.startsWith(u8, trimmed, "context ")) return contextResponse(allocator, session, trimmed[8..]);
if (std.mem.startsWith(u8, trimmed, "open ")) {
const bytes = trimmed[5..];
if (!std.unicode.utf8ValidateSlice(bytes)) return allocator.dupe(u8, "err invalid utf8\n");
try session.openFixture(bytes);
return stateResponse(allocator, session);
}
if (std.mem.startsWith(u8, trimmed, "command ")) {
return commandResponse(allocator, session, trimmed[8..]);
}
if (std.mem.startsWith(u8, trimmed, "tool ")) {
return toolResponse(allocator, session, trimmed[5..]);
}
return allocator.dupe(u8, "err unknown request\n");
}
fn contextResponse(allocator: std.mem.Allocator, session: *session_mod.Session, payload: []const u8) ![]u8 {
const split = std.mem.indexOfScalar(u8, payload, ' ');
if (split) |index| return context_mod.sessionContextAlloc(allocator, session, payload[0..index], payload[index + 1 ..], .{});
return context_mod.sessionContextAlloc(allocator, session, payload, null, .{});
}
fn toolResponse(allocator: std.mem.Allocator, session: *session_mod.Session, payload: []const u8) ![]u8 {
const split = std.mem.indexOfScalar(u8, payload, ' ') orelse return allocator.dupe(u8, "err invalid tool request\n");
const name = payload[0..split];
const command = payload[split + 1 ..];
if (!validToolName(name) or command.len == 0 or std.mem.startsWith(u8, command, "tool ")) return allocator.dupe(u8, "err invalid tool request\n");
if (!validToolPayload(command)) return allocator.dupe(u8, "err invalid tool request\n");
const command_response = try commandResponse(allocator, session, command);
defer allocator.free(command_response);
const status: []const u8 = if (std.mem.startsWith(u8, command_response, "ok ")) "ok" else "err";
return std.fmt.allocPrint(
allocator,
"tool-log\t{s}\t{s}\t{s}\n{s}",
.{ name, command, status, command_response },
);
}
fn validToolName(name: []const u8) bool {
if (name.len == 0 or name.len > 32) return false;
for (name) |byte| {
if (!((byte >= 'a' and byte <= 'z') or (byte >= 'A' and byte <= 'Z') or (byte >= '0' and byte <= '9') or byte == '_' or byte == '-')) return false;
}
return true;
}
fn validToolPayload(command: []const u8) bool {
for (command) |byte| {
if (byte == 0 or byte == '\t' or byte == '\n' or byte == '\r') return false;
}
return true;
}
fn parseToolLog(log_line: []const u8) ?struct { name: []const u8, command: []const u8, status: []const u8 } {
const trimmed = std.mem.trimEnd(u8, log_line, "\r\n");
if (!std.mem.startsWith(u8, trimmed, "tool-log\t")) return null;
var fields = std.mem.splitScalar(u8, trimmed, '\t');
_ = fields.next() orelse return null;
const name = fields.next() orelse return null;
const command = fields.next() orelse return null;
const status = fields.next() orelse return null;
if (fields.next() != null) return null;
if (!validToolName(name) or !validToolPayload(command)) return null;
return .{ .name = name, .command = command, .status = status };
}
fn commandResponse(allocator: std.mem.Allocator, session: *session_mod.Session, command: []const u8) ![]u8 {
if (std.mem.eql(u8, command, "move_left")) return dispatchAndRespond(allocator, session, .move_left);
if (std.mem.eql(u8, command, "move_right")) return dispatchAndRespond(allocator, session, .move_right);
if (std.mem.eql(u8, command, "move_up")) return dispatchAndRespond(allocator, session, .move_up);
if (std.mem.eql(u8, command, "move_down")) return dispatchAndRespond(allocator, session, .move_down);
if (std.mem.eql(u8, command, "move_word_forward")) return dispatchAndRespond(allocator, session, .move_word_forward);
if (std.mem.eql(u8, command, "move_word_back")) return dispatchAndRespond(allocator, session, .move_word_back);
if (std.mem.eql(u8, command, "move_word_end")) return dispatchAndRespond(allocator, session, .move_word_end);
if (std.mem.eql(u8, command, "move_line_start")) return dispatchAndRespond(allocator, session, .move_line_start);
if (std.mem.eql(u8, command, "move_line_end")) return dispatchAndRespond(allocator, session, .move_line_end);
if (std.mem.eql(u8, command, "delete_backward")) return dispatchAndRespond(allocator, session, .delete_backward);
if (std.mem.eql(u8, command, "delete_forward")) return dispatchAndRespond(allocator, session, .delete_forward);
if (std.mem.eql(u8, command, "delete_line")) return dispatchAndRespond(allocator, session, .delete_line);
if (std.mem.eql(u8, command, "change_line")) return dispatchAndRespond(allocator, session, .change_line);
if (std.mem.eql(u8, command, "open_line_below")) return dispatchAndRespond(allocator, session, .open_line_below);
if (std.mem.eql(u8, command, "open_line_above")) return dispatchAndRespond(allocator, session, .open_line_above);
if (std.mem.startsWith(u8, command, "replace_char ")) return dispatchAndRespond(allocator, session, .{ .replace_char = command[13..] });
if (std.mem.startsWith(u8, command, "insert ")) return dispatchAndRespond(allocator, session, .{ .insert = command[7..] });
if (std.mem.startsWith(u8, command, "panel_open ")) return panelRespond(allocator, session, .{ .open = command[11..] });
if (std.mem.startsWith(u8, command, "list_open ")) return listOpenRespond(allocator, session, command[10..]);
if (std.mem.startsWith(u8, command, "list_filter ")) return listRespond(allocator, session, .{ .filter = command[12..] });
if (std.mem.eql(u8, command, "list_down")) return listRespond(allocator, session, .down);
if (std.mem.eql(u8, command, "list_up")) return listRespond(allocator, session, .up);
if (std.mem.eql(u8, command, "list_select")) return listRespond(allocator, session, .select);
if (std.mem.eql(u8, command, "list_cancel")) return listRespond(allocator, session, .cancel);
if (std.mem.eql(u8, command, "panel_close")) return panelRespond(allocator, session, .close);
if (std.mem.eql(u8, command, "panel_next")) return panelRespond(allocator, session, .next);
if (std.mem.eql(u8, command, "panel_prev")) return panelRespond(allocator, session, .previous);
if (std.mem.startsWith(u8, command, "pair ")) {
const pair = pairByName(command[5..]) orelse return allocator.dupe(u8, "err unknown pair\n");
return dispatchAndRespond(allocator, session, .{ .insert_pair = pair });
}
return allocator.dupe(u8, "err unknown command\n");
}
fn pairByName(name: []const u8) ?session_mod.Pair {
if (std.mem.eql(u8, name, "parens")) return .{ .open = "(", .close = ")" };
if (std.mem.eql(u8, name, "brackets")) return .{ .open = "[", .close = "]" };
if (std.mem.eql(u8, name, "braces")) return .{ .open = "{", .close = "}" };
if (std.mem.eql(u8, name, "double_quote")) return .{ .open = "\"", .close = "\"" };
if (std.mem.eql(u8, name, "single_quote")) return .{ .open = "'", .close = "'" };
if (std.mem.eql(u8, name, "backtick")) return .{ .open = "`", .close = "`" };
return null;
}
fn dispatchAndRespond(allocator: std.mem.Allocator, session: *session_mod.Session, command: session_mod.Command) ![]u8 {
session.dispatch(command) catch |err| switch (err) {
error.InvalidUtf8Insertion => return allocator.dupe(u8, "err invalid utf8\n"),
error.NoBufferOpen => return allocator.dupe(u8, "err no buffer open\n"),
else => return err,
};
return stateResponse(allocator, session);
}
const PanelCommand = union(enum) {
open: []const u8,
close,
next,
previous,
};
fn panelRespond(allocator: std.mem.Allocator, session: *session_mod.Session, command: PanelCommand) ![]u8 {
switch (command) {
.open => |title| session.openPanel(title) catch |err| switch (err) {
error.InvalidPanelTitle => return allocator.dupe(u8, "err invalid panel title\n"),
else => return err,
},
.close => session.closePanel() catch |err| switch (err) {
error.NoPanelOpen => return allocator.dupe(u8, "err no panel open\n"),
else => return err,
},
.next => session.nextPanel() catch |err| switch (err) {
error.NoPanelOpen => return allocator.dupe(u8, "err no panel open\n"),
else => return err,
},
.previous => session.previousPanel() catch |err| switch (err) {
error.NoPanelOpen => return allocator.dupe(u8, "err no panel open\n"),
else => return err,
},
}
return stateResponse(allocator, session);
}
const ListCommand = union(enum) {
filter: []const u8,
down,
up,
select,
cancel,
};
fn listOpenRespond(allocator: std.mem.Allocator, session: *session_mod.Session, payload: []const u8) ![]u8 {
const split = std.mem.indexOfScalar(u8, payload, ' ') orelse return allocator.dupe(u8, "err invalid list open\n");
const title = payload[0..split];
const items_payload = payload[split + 1 ..];
var items = std.ArrayList([]const u8).empty;
defer items.deinit(allocator);
var iter = std.mem.splitScalar(u8, items_payload, '|');
while (iter.next()) |item| try items.append(allocator, item);
session.openListPanel(title, items.items) catch |err| switch (err) {
error.InvalidPanelTitle => return allocator.dupe(u8, "err invalid panel title\n"),
error.InvalidListItem => return allocator.dupe(u8, "err invalid list item\n"),
error.EmptyList => return allocator.dupe(u8, "err empty list\n"),
else => return err,
};
return stateResponse(allocator, session);
}
fn listRespond(allocator: std.mem.Allocator, session: *session_mod.Session, command: ListCommand) ![]u8 {
switch (command) {
.filter => |filter| session.filterListPanel(filter) catch |err| return listError(allocator, err),
.down => session.listPanelDown() catch |err| return listError(allocator, err),
.up => session.listPanelUp() catch |err| return listError(allocator, err),
.select => _ = session.selectListPanel() catch |err| return listError(allocator, err),
.cancel => session.cancelListPanel() catch |err| return listError(allocator, err),
}
return stateResponse(allocator, session);
}
fn listError(allocator: std.mem.Allocator, err: anyerror) ![]u8 {
return switch (err) {
error.NoPanelOpen => allocator.dupe(u8, "err no panel open\n"),
error.ActivePanelIsNotList => allocator.dupe(u8, "err active panel is not list\n"),
error.InvalidListFilter => allocator.dupe(u8, "err invalid list filter\n"),
error.EmptyList => allocator.dupe(u8, "err empty list\n"),
else => err,
};
}
fn stateResponse(allocator: std.mem.Allocator, session: *session_mod.Session) ![]u8 {
const snap = try session.snapshot();
const panel_path = try session.panelPathAlloc(allocator);
defer allocator.free(panel_path);
const panel_summary = try session.panelSummaryAlloc(allocator);
defer allocator.free(panel_summary);
return std.fmt.allocPrint(
allocator,
"ok state cursor_byte={d} cursor_cell={d} bytes_len={d} panel_depth={d} active_panel={s} panel_path={s} panel_summary={s}\n",
.{
snap.cursor_byte,
snap.cursor_cell,
snap.bytes.len,
snap.panel_depth,
if (snap.active_panel_title) |title| title else "-",
panel_path,
panel_summary,
},
);
}
test "regular: trusted tool command logs and replays deterministic state" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
try session.openFixture("ab");
const response = try handleLine(std.testing.allocator, &session, "tool pi insert c\n");
defer std.testing.allocator.free(response);
try std.testing.expect(std.mem.startsWith(u8, response, "tool-log\tpi\tinsert c\tok\n"));
try std.testing.expect(std.mem.indexOf(u8, response, "bytes_len=3") != null);
const log_end = std.mem.indexOfScalar(u8, response, '\n').?;
var replayed = session_mod.Session.init(std.testing.allocator);
defer replayed.deinit();
try replayed.openFixture("ab");
const replay_response = try replayToolLogLine(std.testing.allocator, &replayed, response[0..log_end]);
defer std.testing.allocator.free(replay_response);
try std.testing.expect(std.mem.indexOf(u8, replay_response, "bytes_len=3") != null);
}
test "adversarial: tool path rejects hidden or malformed mutations" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
try session.openFixture("ab");
{
const response = try handleLine(std.testing.allocator, &session, "tool bad* insert c\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("err invalid tool request\n", response);
}
{
const response = try handleLine(std.testing.allocator, &session, "tool pi tool nested\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("err invalid tool request\n", response);
}
{
const response = try replayToolLogLine(std.testing.allocator, &session, "tool-log\tpi\tinsert c\terr");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("err logged command failed\n", response);
}
}
test "regular: protocol context request returns compact agent context" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
try session.openFixture("abc");
try session.openListPanel("diag", &.{ "one", "two" });
const response = try handleLine(std.testing.allocator, &session, "context fix_bug inspect\n");
defer std.testing.allocator.free(response);
try std.testing.expect(std.mem.indexOf(u8, response, "context:v1\n") != null);
try std.testing.expect(std.mem.indexOf(u8, response, "active_panel=diag") != null);
try std.testing.expect(std.mem.indexOf(u8, response, "panel_summary=") != null);
try std.testing.expect(std.mem.indexOf(u8, response, "task=fix_bug") != null);
try std.testing.expect(std.mem.indexOf(u8, response, "intent=inspect") != null);
}
test "regular: protocol opens bytes, reports state, and dispatches commands" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
const open = try handleLine(std.testing.allocator, &session, "open café\n");
defer std.testing.allocator.free(open);
try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=5 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", open);
const moved = try handleLine(std.testing.allocator, &session, "command move_right\n");
defer std.testing.allocator.free(moved);
try std.testing.expectEqualStrings("ok state cursor_byte=1 cursor_cell=1 bytes_len=5 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", moved);
const inserted = try handleLine(std.testing.allocator, &session, "command insert 🔥\n");
defer std.testing.allocator.free(inserted);
try std.testing.expectEqualStrings("ok state cursor_byte=5 cursor_cell=3 bytes_len=9 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", inserted);
}
test "regular: protocol delete command removes a whole UTF-8 codepoint" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
var response = try handleLine(std.testing.allocator, &session, "open aé\n");
std.testing.allocator.free(response);
response = try handleLine(std.testing.allocator, &session, "command move_right\n");
std.testing.allocator.free(response);
response = try handleLine(std.testing.allocator, &session, "command move_right\n");
std.testing.allocator.free(response);
response = try handleLine(std.testing.allocator, &session, "command delete_backward\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("ok state cursor_byte=1 cursor_cell=1 bytes_len=1 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", response);
}
test "adversarial: protocol rejects unknown requests and commands" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
const request = try handleLine(std.testing.allocator, &session, "subscribe everything\n");
defer std.testing.allocator.free(request);
try std.testing.expectEqualStrings("err unknown request\n", request);
try session.openFixture("abc");
const command = try handleLine(std.testing.allocator, &session, "command plugin.load\n");
defer std.testing.allocator.free(command);
try std.testing.expectEqualStrings("err unknown command\n", command);
}
test "adversarial: protocol reports no buffer and invalid utf8 without corrupting state" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
const no_buffer = try handleLine(std.testing.allocator, &session, "state\n");
defer std.testing.allocator.free(no_buffer);
try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", no_buffer);
try session.openFixture("safe");
const before = try session.snapshot();
const bad = [_]u8{ 'c', 'o', 'm', 'm', 'a', 'n', 'd', ' ', 'i', 'n', 's', 'e', 'r', 't', ' ', 0xc3, 0x28 };
const invalid = try handleLine(std.testing.allocator, &session, &bad);
defer std.testing.allocator.free(invalid);
try std.testing.expectEqualStrings("err invalid utf8\n", invalid);
const after = try session.snapshot();
try std.testing.expectEqualStrings(before.bytes, after.bytes);
}
test "regular: protocol pair commands insert delimiters with cursor between them" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
var response = try handleLine(std.testing.allocator, &session, "open call\n");
std.testing.allocator.free(response);
response = try handleLine(std.testing.allocator, &session, "command move_right\n");
std.testing.allocator.free(response);
response = try handleLine(std.testing.allocator, &session, "command move_right\n");
std.testing.allocator.free(response);
response = try handleLine(std.testing.allocator, &session, "command move_right\n");
std.testing.allocator.free(response);
response = try handleLine(std.testing.allocator, &session, "command move_right\n");
std.testing.allocator.free(response);
response = try handleLine(std.testing.allocator, &session, "command pair parens\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("ok state cursor_byte=5 cursor_cell=5 bytes_len=6 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", response);
try std.testing.expectEqualStrings("call()", (try session.snapshot()).bytes);
}
test "adversarial: protocol rejects unknown pair names" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
try session.openFixture("abc");
const response = try handleLine(std.testing.allocator, &session, "command pair snippet_everything\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("err unknown pair\n", response);
try std.testing.expectEqualStrings("abc", (try session.snapshot()).bytes);
}
test "regular: protocol opens switches and closes generic panels" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
{
const response = try handleLine(std.testing.allocator, &session, "command panel_open files\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files] panel_summary=empty\n", response);
}
{
const response = try handleLine(std.testing.allocator, &session, "command panel_open diagnostics\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=2 active_panel=diagnostics panel_path=files>[diagnostics] panel_summary=empty\n", response);
}
{
const response = try handleLine(std.testing.allocator, &session, "command panel_prev\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=2 active_panel=files panel_path=[files]>diagnostics panel_summary=empty\n", response);
}
{
const response = try handleLine(std.testing.allocator, &session, "command panel_close\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=diagnostics panel_path=[diagnostics] panel_summary=empty\n", response);
}
}
test "adversarial: protocol rejects invalid panel titles and empty panel actions" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
{
const response = try handleLine(std.testing.allocator, &session, "command panel_open bad title\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("err invalid panel title\n", response);
}
{
const response = try handleLine(std.testing.allocator, &session, "command panel_close\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("err no panel open\n", response);
}
}
test "regular: protocol list panel filters moves selects and remains inspectable" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
{
const response = try handleLine(std.testing.allocator, &session, "command list_open files src/main.zig|src/panel.zig|README.md\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files] panel_summary=list:filter=-,cursor=0,visible=3,selected=-\n", response);
}
{
const response = try handleLine(std.testing.allocator, &session, "command list_filter src\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files] panel_summary=list:filter=src,cursor=0,visible=2,selected=-\n", response);
}
{
const response = try handleLine(std.testing.allocator, &session, "command list_down\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files] panel_summary=list:filter=src,cursor=1,visible=2,selected=-\n", response);
}
{
const response = try handleLine(std.testing.allocator, &session, "command list_select\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=1 active_panel=files panel_path=[files] panel_summary=list:filter=src,cursor=1,visible=2,selected=src/panel.zig\n", response);
}
}
test "regular: protocol list cancel closes list panel" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
var response = try handleLine(std.testing.allocator, &session, "command list_open files a.zig|b.zig\n");
std.testing.allocator.free(response);
response = try handleLine(std.testing.allocator, &session, "command list_cancel\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("ok state cursor_byte=0 cursor_cell=0 bytes_len=0 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", response);
}
test "adversarial: protocol list errors are explicit" {
var session = session_mod.Session.init(std.testing.allocator);
defer session.deinit();
{
const response = try handleLine(std.testing.allocator, &session, "command list_open files bad item\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("err invalid list item\n", response);
}
{
const response = try handleLine(std.testing.allocator, &session, "command list_filter src\n");
defer std.testing.allocator.free(response);
try std.testing.expectEqualStrings("err no panel open\n", response);
}
}