Add IPC envelope and tool messages to proto
Implemented-By: Laguna S 2.1 (OpenCode)
This commit is contained in:
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
pub mod class;
|
pub mod class;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
|
pub mod wire;
|
||||||
|
|
||||||
pub use class::DataClass;
|
pub use class::DataClass;
|
||||||
pub use ids::{CallId, Epoch, Hash32, SessionId, Timestamp, ValueError};
|
pub use ids::{CallId, Epoch, Hash32, SessionId, Timestamp, ValueError};
|
||||||
|
pub use wire::{
|
||||||
|
DenyReason, Envelope, ErrorCode, Message, PROTOCOL_VERSION, ToolRequest, ToolResponse,
|
||||||
|
WireError,
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::{CallId, DataClass, SessionId, Timestamp};
|
||||||
|
|
||||||
|
pub const PROTOCOL_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Envelope {
|
||||||
|
pub v: u32,
|
||||||
|
pub id: u64,
|
||||||
|
pub r#final: bool,
|
||||||
|
pub msg: Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(
|
||||||
|
tag = "kind",
|
||||||
|
content = "body",
|
||||||
|
deny_unknown_fields,
|
||||||
|
rename_all = "snake_case"
|
||||||
|
)]
|
||||||
|
pub enum Message {
|
||||||
|
ToolRequest(ToolRequest),
|
||||||
|
ToolResponse(ToolResponse),
|
||||||
|
Error(WireError),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WireError {
|
||||||
|
pub code: ErrorCode,
|
||||||
|
pub detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ErrorCode {
|
||||||
|
BadFrame,
|
||||||
|
BadVersion,
|
||||||
|
BadMessage,
|
||||||
|
Internal,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ToolRequest {
|
||||||
|
pub session: SessionId,
|
||||||
|
pub call: CallId,
|
||||||
|
pub tool: String,
|
||||||
|
pub arguments: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "status", deny_unknown_fields, rename_all = "snake_case")]
|
||||||
|
pub enum ToolResponse {
|
||||||
|
PendingApproval {
|
||||||
|
approval: String,
|
||||||
|
expires: Timestamp,
|
||||||
|
},
|
||||||
|
Result {
|
||||||
|
content: String,
|
||||||
|
class: DataClass,
|
||||||
|
untrusted: bool,
|
||||||
|
truncated: bool,
|
||||||
|
},
|
||||||
|
Failed {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
Denied {
|
||||||
|
reason: DenyReason,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum DenyReason {
|
||||||
|
NoGrant,
|
||||||
|
GrantExpired,
|
||||||
|
TaintTooHigh,
|
||||||
|
DeniedByGrant,
|
||||||
|
ApprovalRefused,
|
||||||
|
ApprovalExpired,
|
||||||
|
}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":0,"final":true,"msg":{"kind":"error","body":{"code":"bad_version","detail":"expected 1"}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":7,"final":true,"msg":{"kind":"tool_request","body":{"session":"mm-thread-42","call":3,"tool":"read_file","arguments":"{\"path\":\"/etc/hosts\"}"}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":9,"final":true,"msg":{"kind":"tool_response","body":{"status":"denied","reason":"taint_too_high"}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":8,"final":true,"msg":{"kind":"tool_response","body":{"status":"failed","message":"exit status 2"}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":7,"final":false,"msg":{"kind":"tool_response","body":{"status":"pending_approval","approval":"ap-0001","expires":"2026-09-17T08:35:00.000Z"}}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"v":1,"id":7,"final":true,"msg":{"kind":"tool_response","body":{"status":"result","content":"127.0.0.1 localhost\n","class":"private","untrusted":true,"truncated":false}}}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
//! Tests for IPC messages against byte-exact fixtures. Do not edit these or the fixtures.
|
||||||
|
|
||||||
|
use proto::{
|
||||||
|
CallId, DataClass, DenyReason, Envelope, ErrorCode, Message, SessionId, Timestamp, ToolRequest,
|
||||||
|
ToolResponse, WireError,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> String {
|
||||||
|
let path = format!("{}/tests/fixtures/wire/{name}", env!("CARGO_MANIFEST_DIR"));
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{path}: {e}"));
|
||||||
|
text.trim_end_matches('\n').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fixture must decode to `want`, and `want` must encode to exactly the fixture's bytes.
|
||||||
|
fn check(name: &str, want: Envelope) {
|
||||||
|
let text = fixture(name);
|
||||||
|
let got: Envelope = serde_json::from_str(&text).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||||
|
assert_eq!(got, want, "{name}: decoded value");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&want).unwrap(),
|
||||||
|
text,
|
||||||
|
"{name}: encoded bytes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response(id: u64, r#final: bool, body: ToolResponse) -> Envelope {
|
||||||
|
Envelope {
|
||||||
|
v: 1,
|
||||||
|
id,
|
||||||
|
r#final,
|
||||||
|
msg: Message::ToolResponse(body),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_request() {
|
||||||
|
let body = ToolRequest {
|
||||||
|
session: SessionId::new("mm-thread-42").unwrap(),
|
||||||
|
call: CallId(3),
|
||||||
|
tool: "read_file".to_string(),
|
||||||
|
arguments: r#"{"path":"/etc/hosts"}"#.to_string(),
|
||||||
|
};
|
||||||
|
check(
|
||||||
|
"tool_request.json",
|
||||||
|
Envelope {
|
||||||
|
v: 1,
|
||||||
|
id: 7,
|
||||||
|
r#final: true,
|
||||||
|
msg: Message::ToolRequest(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_response_pending() {
|
||||||
|
let body = ToolResponse::PendingApproval {
|
||||||
|
approval: "ap-0001".to_string(),
|
||||||
|
expires: Timestamp::parse("2026-09-17T08:35:00.000Z").unwrap(),
|
||||||
|
};
|
||||||
|
check("tool_response_pending.json", response(7, false, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_response_result() {
|
||||||
|
let body = ToolResponse::Result {
|
||||||
|
content: "127.0.0.1 localhost\n".to_string(),
|
||||||
|
class: DataClass::Private,
|
||||||
|
untrusted: true,
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
check("tool_response_result.json", response(7, true, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_response_failed() {
|
||||||
|
let body = ToolResponse::Failed {
|
||||||
|
message: "exit status 2".to_string(),
|
||||||
|
};
|
||||||
|
check("tool_response_failed.json", response(8, true, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_response_denied() {
|
||||||
|
let body = ToolResponse::Denied {
|
||||||
|
reason: DenyReason::TaintTooHigh,
|
||||||
|
};
|
||||||
|
check("tool_response_denied.json", response(9, true, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn error_message() {
|
||||||
|
let body = WireError {
|
||||||
|
code: ErrorCode::BadVersion,
|
||||||
|
detail: "expected 1".to_string(),
|
||||||
|
};
|
||||||
|
check(
|
||||||
|
"error.json",
|
||||||
|
Envelope {
|
||||||
|
v: 1,
|
||||||
|
id: 0,
|
||||||
|
r#final: true,
|
||||||
|
msg: Message::Error(body),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn deny_reasons_and_error_codes_are_snake_case() {
|
||||||
|
let reasons = [
|
||||||
|
(DenyReason::NoGrant, "no_grant"),
|
||||||
|
(DenyReason::GrantExpired, "grant_expired"),
|
||||||
|
(DenyReason::TaintTooHigh, "taint_too_high"),
|
||||||
|
(DenyReason::DeniedByGrant, "denied_by_grant"),
|
||||||
|
(DenyReason::ApprovalRefused, "approval_refused"),
|
||||||
|
(DenyReason::ApprovalExpired, "approval_expired"),
|
||||||
|
];
|
||||||
|
for (value, text) in reasons {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&value).unwrap(),
|
||||||
|
format!("\"{text}\"")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let codes = [
|
||||||
|
(ErrorCode::BadFrame, "bad_frame"),
|
||||||
|
(ErrorCode::BadVersion, "bad_version"),
|
||||||
|
(ErrorCode::BadMessage, "bad_message"),
|
||||||
|
(ErrorCode::Internal, "internal"),
|
||||||
|
];
|
||||||
|
for (value, text) in codes {
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&value).unwrap(),
|
||||||
|
format!("\"{text}\"")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_and_missing_fields_are_rejected() {
|
||||||
|
let good = fixture("tool_request.json");
|
||||||
|
assert!(serde_json::from_str::<Envelope>(&good).is_ok());
|
||||||
|
let bad = [
|
||||||
|
// extra field in the envelope
|
||||||
|
good.replacen("{\"v\":1,", "{\"v\":1,\"extra\":0,", 1),
|
||||||
|
// extra field beside kind and body
|
||||||
|
good.replacen(
|
||||||
|
"\"kind\":\"tool_request\",",
|
||||||
|
"\"kind\":\"tool_request\",\"x\":1,",
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
// extra field in the body
|
||||||
|
good.replacen("\"call\":3,", "\"call\":3,\"priority\":9,", 1),
|
||||||
|
// missing field in the body
|
||||||
|
good.replacen("\"call\":3,", "", 1),
|
||||||
|
// missing `final`
|
||||||
|
good.replacen("\"final\":true,", "", 1),
|
||||||
|
// unknown kind
|
||||||
|
good.replacen("tool_request", "tool_demand", 1),
|
||||||
|
// invalid session id inside a message
|
||||||
|
good.replacen("mm-thread-42", "../../etc", 1),
|
||||||
|
];
|
||||||
|
for text in bad {
|
||||||
|
assert!(
|
||||||
|
serde_json::from_str::<Envelope>(&text).is_err(),
|
||||||
|
"accepted {text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_field_in_a_response_variant_is_rejected() {
|
||||||
|
let good = fixture("tool_response_denied.json");
|
||||||
|
let bad = good.replacen("\"reason\":", "\"note\":\"x\",\"reason\":", 1);
|
||||||
|
assert!(serde_json::from_str::<Envelope>(&good).is_ok());
|
||||||
|
assert!(serde_json::from_str::<Envelope>(&bad).is_err());
|
||||||
|
let bad_status = good.replacen("denied", "refused", 1);
|
||||||
|
assert!(serde_json::from_str::<Envelope>(&bad_status).is_err());
|
||||||
|
}
|
||||||
@@ -7,5 +7,6 @@ reviewer adds findings under "Reviews" once per milestone.
|
|||||||
|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|
|
||||||
| M1/01-workspace-and-gate | 2026-09-17 | done | 1 | pass | none | Crate skeletons, Cargo files and the given Makefile/deny.toml/test-gate-scripts.sh were already present untracked from a prior attempt; I verified them against the plan and created only the missing gate scripts, dependencies.md, egress.md and this log row. |
|
| M1/01-workspace-and-gate | 2026-09-17 | done | 1 | pass | none | Crate skeletons, Cargo files and the given Makefile/deny.toml/test-gate-scripts.sh were already present untracked from a prior attempt; I verified them against the plan and created only the missing gate scripts, dependencies.md, egress.md and this log row. |
|
||||||
| M1/02-proto-values | 2026-09-17 | done | 1 | pass | none | Implemented ValueError, SessionId, Epoch, CallId, Hash32 and Timestamp in crates/proto/src/ids.rs and DataClass in class.rs, using serde try_from/into for string-backed JSON validation, a hand-written hex encoder and humantime for RFC 3339 parsing with canonical re-serialization. |
|
| M1/02-proto-values | 2026-09-17 | done | 1 | pass | none | Implemented ValueError, SessionId, Epoch, CallId, Hash32 and Timestamp in crates/proto/src/ids.rs and DataClass in class.rs, using serde try_from/into for string-backed JSON validation, a hand-written hex encoder and humantime for RFC 3339 parsing with canonical re-serialization. |
|
||||||
|
| M1/03-proto-wire | 2026-09-17 | done | 2 | pass | none | Added Envelope, Message, WireError, ErrorCode, ToolRequest, ToolResponse and DenyReason in crates/proto/src/wire.rs, re-exported from lib.rs; all 9 fixture tests pass and `make gate` prints `gate: ok`. |
|
||||||
|
|
||||||
## Reviews
|
## Reviews
|
||||||
|
|||||||
Reference in New Issue
Block a user