Compare commits
336
Commits
d115819f51
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9295f4439 | ||
|
|
5dfc50f293 | ||
|
|
eb05300a32 | ||
|
|
5d7213b9a4 | ||
|
|
d0f22d871a | ||
|
|
922e5e3fc3 | ||
|
|
bfbddfa443 | ||
|
|
f22b69bb92 | ||
|
|
f113bfa0ee | ||
|
|
4134f9c2bb | ||
|
|
846d35446b | ||
|
|
d08a21e38c | ||
|
|
f7ea2362dd | ||
|
|
e8477882b5 | ||
|
|
af074254ec | ||
|
|
df8cd2353b | ||
|
|
c254870ed6 | ||
|
|
8018f648e4 | ||
|
|
bb70c2304e | ||
|
|
09b9755a16 | ||
|
|
2924e43773 | ||
|
|
86b2981d63 | ||
|
|
c9cd2af845 | ||
|
|
7a2d132c16 | ||
|
|
9e7392256b | ||
|
|
82616c2a41 | ||
|
|
c05854b4b1 | ||
|
|
226d0ecfce | ||
|
|
54dea496ff | ||
|
|
24694dfddc | ||
|
|
1c35089c77 | ||
|
|
05649dd66f | ||
|
|
290edf6808 | ||
|
|
bf23f5a4e4 | ||
|
|
d91181912b | ||
|
|
7b594ded0d | ||
|
|
2f74e00e07 | ||
|
|
52a1ce5607 | ||
|
|
807a476c88 | ||
|
|
7252e61a23 | ||
|
|
6fffe4517b | ||
|
|
ab138d72fc | ||
|
|
5627f47e2b | ||
|
|
2c1f5dfafe | ||
|
|
b508fef698 | ||
|
|
27f3b36ce5 | ||
|
|
5d0e03dc53 | ||
|
|
4f7dcfda61 | ||
|
|
1c03758fc1 | ||
|
|
e7351c16b0 | ||
|
|
00d96b7163 | ||
|
|
2b4bf4b7bd | ||
|
|
e7ebc92f83 | ||
|
|
0f82d6fa8f | ||
|
|
5a317e6d2a | ||
|
|
5329a93e3e | ||
|
|
94d458883e | ||
|
|
b824195b36 | ||
|
|
318c1a9ce9 | ||
|
|
9f13446afd | ||
|
|
479bf0872a | ||
|
|
36932321e0 | ||
|
|
70ce8479b2 | ||
|
|
57b57aed67 | ||
|
|
0cd4811739 | ||
|
|
165153c63c | ||
|
|
3a13301f75 | ||
|
|
732fe5c4c1 | ||
|
|
7c238edc1a | ||
|
|
55ad8c4265 | ||
|
|
1eecccc21e | ||
|
|
c65f145376 | ||
|
|
8661505b5a | ||
|
|
4f096f26b5 | ||
|
|
e7506c5eb8 | ||
|
|
967274bfbd | ||
|
|
6d15569b9b | ||
|
|
8810d3245e | ||
|
|
bd70eda4e0 | ||
|
|
771120778b | ||
|
|
d077302b92 | ||
|
|
c82113f7c4 | ||
|
|
51f0c7e019 | ||
|
|
26cc5e9306 | ||
|
|
6733aeef01 | ||
|
|
c215c72873 | ||
|
|
87a1b6f1eb | ||
|
|
882316fb3b | ||
|
|
74ddd568eb | ||
|
|
4c2bd3ea78 | ||
|
|
dd312b71ee | ||
|
|
2141672310 | ||
|
|
3600843683 | ||
|
|
38a953cd91 | ||
|
|
fc1115b167 | ||
|
|
9b7a58d5c4 | ||
|
|
7ad6030498 | ||
|
|
5087abc5ab | ||
|
|
90398391dd | ||
|
|
6486caf992 | ||
|
|
0ce4fc04ba | ||
|
|
3812126897 | ||
|
|
ec275da601 | ||
|
|
5f0c960048 | ||
|
|
e35cb50f3a | ||
|
|
b8f54eeb6d | ||
|
|
959ed7a554 | ||
|
|
0666434ec8 | ||
|
|
14101d45dc | ||
|
|
4e2f6fcb9c | ||
|
|
043465dc6b | ||
|
|
9b5fad3970 | ||
|
|
45e24375d5 | ||
|
|
d9e9c0f13d | ||
|
|
de4159b20f | ||
|
|
6826db7e0c | ||
|
|
a1fbe1e754 | ||
|
|
8b261cdae1 | ||
|
|
881e241e67 | ||
|
|
00903264f3 | ||
|
|
f598a36ac0 | ||
|
|
2d195f4058 | ||
|
|
06883c51b7 | ||
|
|
a35d9a3d45 | ||
|
|
181b0d637e | ||
|
|
b0733e53b2 | ||
|
|
0710738173 | ||
|
|
960a1b4364 | ||
|
|
a06f6bfb3d | ||
|
|
618880d38d | ||
|
|
79b76c8789 | ||
|
|
897c025b85 | ||
|
|
5b12c3e085 | ||
|
|
623c31615d | ||
|
|
af719e387d | ||
|
|
dddb21bd70 | ||
|
|
e8d4e964cb | ||
|
|
eb24cb04dc | ||
|
|
e84057f402 | ||
|
|
15c3006d09 | ||
|
|
b3209c2012 | ||
|
|
6f5a424661 | ||
|
|
8d47d2670b | ||
|
|
b19de45a82 | ||
|
|
1e88893ef9 | ||
|
|
1fd2e09971 | ||
|
|
f85d831104 | ||
|
|
2b24db5a15 | ||
|
|
e1945d977c | ||
|
|
a10875d68e | ||
|
|
f35dcef4d0 | ||
|
|
f5ebae1134 | ||
|
|
6be4e8388b | ||
|
|
9e3f87ac52 | ||
|
|
ceb01f6eaa | ||
|
|
1b13563e19 | ||
|
|
848a1e9a5b | ||
|
|
a77453338c | ||
|
|
d12bc2ea0b | ||
|
|
a1df3742b1 | ||
|
|
d8818b776b | ||
|
|
40c36ccb1d | ||
|
|
80c151d084 | ||
|
|
c6a8759e7b | ||
|
|
f17b5212c7 | ||
|
|
85f70d4bc1 | ||
|
|
b284934e34 | ||
|
|
2d33321fab | ||
|
|
0975471986 | ||
|
|
214d6e21dd | ||
|
|
fca5c23345 | ||
|
|
f9b1500ba2 | ||
|
|
931bf3b27d | ||
|
|
6bb671be21 | ||
|
|
b8f2fa479c | ||
|
|
1639be9841 | ||
|
|
907392cce8 | ||
|
|
6776973e4c | ||
|
|
73a3e674c9 | ||
|
|
994e75233b | ||
|
|
8b6e3070dd | ||
|
|
b860c67ea2 | ||
|
|
533477c2e7 | ||
|
|
5aa2dc579a | ||
|
|
280b3d2a25 | ||
|
|
ec74f4cdc4 | ||
|
|
5af9dc8e4a | ||
|
|
ca6d4b4b72 | ||
|
|
cccecbe7ca | ||
|
|
7a64c38493 | ||
|
|
b1361d86e9 | ||
|
|
6181426abd | ||
|
|
cd331b93c6 | ||
|
|
60eabea19a | ||
|
|
7394ec6784 | ||
|
|
28b7cc414b | ||
|
|
86d5cf9403 | ||
|
|
6f0a23e5cc | ||
|
|
2f4a46bbdb | ||
|
|
4872cf2aac | ||
|
|
734d4b44ae | ||
|
|
6810f51f46 | ||
|
|
a1748b552c | ||
|
|
62b2d27b4a | ||
|
|
62e2b44305 | ||
|
|
b5c105545c | ||
|
|
aa0fb2f787 | ||
|
|
1b6d4dbfd8 | ||
|
|
61c20b0995 | ||
|
|
4ef7b03651 | ||
|
|
af615ab177 | ||
|
|
338e39bcdb | ||
|
|
3cfe04be3a | ||
|
|
70d153b365 | ||
|
|
ce93849948 | ||
|
|
3c499719fe | ||
|
|
9e1d3df7ad | ||
|
|
18bac16a23 | ||
|
|
9005856f77 | ||
|
|
e39372a52a | ||
|
|
223a74a9e2 | ||
|
|
f38d9a16ea | ||
|
|
b135fa5e99 | ||
|
|
b7a22146a0 | ||
|
|
07b8b6d41b | ||
|
|
8017c55d82 | ||
|
|
b8e548b92d | ||
|
|
90e38551cf | ||
|
|
3e8164e757 | ||
|
|
3b3381900f | ||
|
|
00ad02d047 | ||
|
|
a774925dec | ||
|
|
8f759911ab | ||
|
|
daf1f6ed50 | ||
|
|
86602240fa | ||
|
|
bfeee487fd | ||
|
|
f9830fe7dd | ||
|
|
de96c5b577 | ||
|
|
6e729ed06c | ||
|
|
f38b2a52b1 | ||
|
|
dd5384df3b | ||
|
|
6576850b7a | ||
|
|
4555aace28 | ||
|
|
47b32ef6ab | ||
|
|
bb5c50f9ab | ||
|
|
32a409c15d | ||
|
|
19514a5d6a | ||
|
|
887184796e | ||
|
|
cd0eb5eb00 | ||
|
|
e8dfb3e68d | ||
|
|
434ce34da7 | ||
|
|
bfb51b7ca9 | ||
|
|
38d9069632 | ||
|
|
e5749b9be0 | ||
|
|
387b5ff7df | ||
|
|
ec6c5beda0 | ||
|
|
8e40fae6f4 | ||
|
|
c961f244ab | ||
|
|
cea0225a6e | ||
|
|
b3189d8e85 | ||
|
|
24bc45a31e | ||
|
|
b02360fd75 | ||
|
|
41c9d2d5c7 | ||
|
|
cc66bd5052 | ||
|
|
d1408d3a2f | ||
|
|
19122fb5ea | ||
|
|
1fb216e6ae | ||
|
|
4b14f7e6b3 | ||
|
|
67767eeee7 | ||
|
|
1bfb4ce119 | ||
|
|
651820dc6d | ||
|
|
75b1557d63 | ||
|
|
23ff518209 | ||
|
|
fab9e0f4b5 | ||
|
|
5ee42b8db8 | ||
|
|
daf438701f | ||
|
|
23ee401287 | ||
|
|
8f598da931 | ||
|
|
956aeed6b2 | ||
|
|
adfa422973 | ||
|
|
10d200d9ba | ||
|
|
7a5228b8e6 | ||
|
|
0eabdf3a1f | ||
|
|
b1808b7c4d | ||
|
|
6645ea0a47 | ||
|
|
b544e53bd7 | ||
|
|
04a4e59100 | ||
|
|
52e914eebc | ||
|
|
9c12fb4372 | ||
|
|
3c18916118 | ||
|
|
be1c030350 | ||
|
|
a7faf2cea6 | ||
|
|
5cc5ece997 | ||
|
|
ffaaf2ecb0 | ||
|
|
6667985883 | ||
|
|
22dcd8a29f | ||
|
|
a2816a053d | ||
|
|
d74660d1d1 | ||
|
|
24c6f772f7 | ||
|
|
a060ffada8 | ||
|
|
b8b12442a0 | ||
|
|
d22899d39e | ||
|
|
af008f802d | ||
|
|
7042ec7d50 | ||
|
|
a2cec5afd1 | ||
|
|
b5a307811b | ||
|
|
5bd49760e5 | ||
|
|
9debf43645 | ||
|
|
81af1f085c | ||
|
|
b67aa2de7d | ||
|
|
9f1cbbd385 | ||
|
|
8a3d9d67d2 | ||
|
|
6218c27fb6 | ||
|
|
b744efd104 | ||
|
|
18384482f6 | ||
|
|
885e8045fe | ||
|
|
7b52bea23b | ||
|
|
a31f002a0a | ||
|
|
8ab7ee05db | ||
|
|
62d71ed17e | ||
|
|
cafa2fa6ea | ||
|
|
95ed9e8719 | ||
|
|
84ea23c2e6 | ||
|
|
ac49740a50 | ||
|
|
bdc7c73143 | ||
|
|
011dbb9a98 | ||
|
|
b9e7d5675b | ||
|
|
c155259c8e | ||
|
|
e33729647c | ||
|
|
e71177900e | ||
|
|
f50fa47a48 | ||
|
|
b71fc9ee54 | ||
|
|
e70cba4618 | ||
|
|
822a084b31 | ||
|
|
c2df3041f9 | ||
|
|
aabc502f30 |
Generated
+816
-12
File diff suppressed because it is too large
Load Diff
+18
@@ -17,6 +17,8 @@ members = [
|
||||
"crates/runtime-api",
|
||||
"crates/server-api",
|
||||
"crates/plugin-pdk",
|
||||
"crates/plugin-plan",
|
||||
"crates/plugin-runtime",
|
||||
"crates/yoi",
|
||||
"crates/protocol",
|
||||
"crates/session-metrics",
|
||||
@@ -24,6 +26,7 @@ members = [
|
||||
"crates/lint-common",
|
||||
"crates/tools",
|
||||
"crates/fs-operation",
|
||||
"crates/error-contract",
|
||||
"crates/flow",
|
||||
"crates/builtin-source",
|
||||
"crates/config-source",
|
||||
@@ -57,6 +60,8 @@ default-members = [
|
||||
"crates/runtime-api",
|
||||
"crates/server-api",
|
||||
"crates/plugin-pdk",
|
||||
"crates/plugin-plan",
|
||||
"crates/plugin-runtime",
|
||||
"crates/yoi",
|
||||
"crates/protocol",
|
||||
"crates/session-metrics",
|
||||
@@ -64,6 +69,7 @@ default-members = [
|
||||
"crates/lint-common",
|
||||
"crates/tools",
|
||||
"crates/fs-operation",
|
||||
"crates/error-contract",
|
||||
"crates/flow",
|
||||
"crates/builtin-source",
|
||||
"crates/config-source",
|
||||
@@ -91,6 +97,16 @@ debug = "line-tables-only"
|
||||
[profile.dev.package."*"]
|
||||
debug = false
|
||||
|
||||
# Keep the real SQLite engine in schema/transaction tests without paying for
|
||||
# an unoptimized C interpreter on every statement. Rust test assertions stay enabled.
|
||||
[profile.test.package.libsqlite3-sys]
|
||||
opt-level = 2
|
||||
|
||||
# Schema/authority integration tests execute substantial parsing and validation.
|
||||
# Optimize that code without disabling test/debug assertions or overflow checks.
|
||||
[profile.test.package.yoi-workspace-server]
|
||||
opt-level = 1
|
||||
|
||||
[workspace.dependencies]
|
||||
# Internal crates
|
||||
client = { path = "crates/client" }
|
||||
@@ -110,6 +126,7 @@ project-record = { path = "crates/project-record" }
|
||||
worker = { path = "crates/worker" }
|
||||
worker-runtime = { path = "crates/worker-runtime" }
|
||||
yoi-plugin-pdk = { path = "crates/plugin-pdk" }
|
||||
plugin-plan = { path = "crates/plugin-plan" }
|
||||
yoi = { path = "crates/yoi" }
|
||||
protocol = { path = "crates/protocol" }
|
||||
session-metrics = { path = "crates/session-metrics" }
|
||||
@@ -123,6 +140,7 @@ secrets = { path = "crates/secrets" }
|
||||
tools = { path = "crates/tools" }
|
||||
builtin-source = { path = "crates/builtin-source" }
|
||||
config-source = { path = "crates/config-source" }
|
||||
error-contract = { path = "crates/error-contract" }
|
||||
fs-operation = { path = "crates/fs-operation" }
|
||||
workdir = { path = "crates/workdir" }
|
||||
tui = { path = "crates/tui" }
|
||||
|
||||
+76
-51
@@ -1480,15 +1480,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
fn get_unanswered_tool_calls(&self, history: &History<A>) -> (Vec<ToolCall>, Vec<String>) {
|
||||
let mut pending_calls = Vec::new();
|
||||
let mut started_call_ids = Vec::new();
|
||||
let mut answered_call_ids = std::collections::HashSet::new();
|
||||
|
||||
for item in history.items() {
|
||||
if let Item::ToolResult { call_id, .. } = item {
|
||||
answered_call_ids.insert(call_id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for item in history.items() {
|
||||
for entry in unanswered_tool_call_entries(history) {
|
||||
if let Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
@@ -1496,8 +1488,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
execution_id,
|
||||
status,
|
||||
..
|
||||
} = item
|
||||
&& !answered_call_ids.contains(call_id)
|
||||
} = &entry.item
|
||||
{
|
||||
if execution_id.is_some() || *status == Some(ItemStatus::InProgress) {
|
||||
started_call_ids.push(call_id.clone());
|
||||
@@ -1639,6 +1630,30 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
} = info;
|
||||
tool_call = call;
|
||||
context.call_id = tool_call.id.clone();
|
||||
if let PreToolAction::SyntheticResult(result) = &action
|
||||
&& result.tool_use_id != expected_tool_use_id
|
||||
{
|
||||
return Err(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
InterceptorError::new(
|
||||
InterceptorErrorCategory::ContractViolation,
|
||||
"synthetic tool result changed immutable tool call identity",
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let execution_id = if matches!(action, PreToolAction::Pause) {
|
||||
None
|
||||
} else {
|
||||
Some(context.execution_id())
|
||||
};
|
||||
self.append_early_tool_call(history, annotate, &tool_call, call_index, execution_id)?;
|
||||
// Policy runs before this call exists in history. Only the successful
|
||||
// append's annotation may bind the identity, overriding hook changes.
|
||||
context.logical_call_id = history
|
||||
.entries()
|
||||
.last()
|
||||
.and_then(|entry| self.interceptor.logical_tool_call_id(&entry.annotation));
|
||||
batch.call_info_map.insert(
|
||||
tool_call.id.clone(),
|
||||
(tool_call.clone(), meta, tool.clone(), context.clone()),
|
||||
@@ -1647,13 +1662,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
match action {
|
||||
PreToolAction::Continue => {
|
||||
let attempt_id = context.execution_id();
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
Some(attempt_id.clone()),
|
||||
)?;
|
||||
batch
|
||||
.attempt_fence
|
||||
.register(tool_call.id.clone(), attempt_id.clone());
|
||||
@@ -1695,34 +1703,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
batch.dispatched_any = true;
|
||||
}
|
||||
PreToolAction::SyntheticResult(result) => {
|
||||
if result.tool_use_id != expected_tool_use_id {
|
||||
return Err(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
InterceptorError::new(
|
||||
InterceptorErrorCategory::ContractViolation,
|
||||
"synthetic tool result changed immutable tool call identity",
|
||||
),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
Some(context.execution_id()),
|
||||
)?;
|
||||
batch.synthetic_results.push_back(result);
|
||||
batch.dispatched_any = true;
|
||||
}
|
||||
PreToolAction::Skip => {
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
Some(context.execution_id()),
|
||||
)?;
|
||||
batch.synthetic_results.push_back(ToolResult::error(
|
||||
&tool_call.id,
|
||||
"Tool execution skipped by interceptor",
|
||||
@@ -1730,13 +1714,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
batch.dispatched_any = true;
|
||||
}
|
||||
PreToolAction::Abort(reason) => {
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
Some(context.execution_id()),
|
||||
)?;
|
||||
batch.synthetic_results.push_back(ToolResult::error(
|
||||
&tool_call.id,
|
||||
format!("Tool execution aborted: {reason}"),
|
||||
@@ -1745,7 +1722,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
return Err(EngineError::Aborted(reason));
|
||||
}
|
||||
PreToolAction::Pause => {
|
||||
self.append_early_tool_call(history, annotate, &tool_call, call_index, None)?;
|
||||
batch.dispatched_any = true;
|
||||
return Ok(EarlyToolAdmission::Pause);
|
||||
}
|
||||
@@ -2010,10 +1986,24 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
let batch_id = format!("tool-batch-{}", self.tool_execution_batch_count);
|
||||
self.tool_execution_batch_count += 1;
|
||||
|
||||
let mut logical_call_ids: HashMap<String, VecDeque<Option<String>>> = HashMap::new();
|
||||
for entry in unanswered_tool_call_entries(history) {
|
||||
if let Item::ToolCall { call_id, .. } = &entry.item {
|
||||
logical_call_ids
|
||||
.entry(call_id.clone())
|
||||
.or_default()
|
||||
.push_back(self.interceptor.logical_tool_call_id(&entry.annotation));
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 1: Apply pre_tool_call interceptor (determine skip/abort/synthetic result)
|
||||
let mut approved_calls = Vec::new();
|
||||
for (call_index, mut tool_call) in tool_calls.into_iter().enumerate() {
|
||||
let expected_tool_use_id = tool_call.id.clone();
|
||||
let logical_call_id = logical_call_ids
|
||||
.get_mut(&tool_call.id)
|
||||
.and_then(VecDeque::pop_front)
|
||||
.flatten();
|
||||
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
||||
if !tool_call.input.is_object() {
|
||||
synthetic_results.push(ToolResult::error(
|
||||
@@ -2079,6 +2069,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
let tool_call = info.call;
|
||||
let mut context = info.context;
|
||||
context.call_id = tool_call.id.clone();
|
||||
context.logical_call_id = logical_call_id;
|
||||
call_info_map.insert(
|
||||
tool_call.id.clone(),
|
||||
(tool_call, info.meta.clone(), info.tool.clone(), context),
|
||||
@@ -2098,6 +2089,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
tool_call = info.call;
|
||||
let mut context = info.context;
|
||||
context.call_id = tool_call.id.clone();
|
||||
context.logical_call_id = logical_call_id;
|
||||
|
||||
call_info_map.insert(
|
||||
tool_call.id.clone(),
|
||||
@@ -2111,7 +2103,8 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
approved_calls.push((tool_call, context, Some(info.tool)));
|
||||
} else {
|
||||
// Unknown tools go into approved list as-is (will error at execution)
|
||||
let context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
||||
let mut context = ToolExecutionContext::new(&tool_call.id, &batch_id, call_index);
|
||||
context.logical_call_id = logical_call_id;
|
||||
approved_calls.push((tool_call, context, None));
|
||||
}
|
||||
}
|
||||
@@ -4333,6 +4326,38 @@ fn item_kind(item: &Item) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pair each result with one preceding unanswered occurrence, never with a later
|
||||
/// call that happens to reuse the provider ID. Keep the exact annotated entries
|
||||
/// so dispatch and restoration resolve the same host-owned identity.
|
||||
fn unanswered_tool_call_entries<A>(history: &History<A>) -> Vec<&HistoryEntry<A>> {
|
||||
let mut calls = Vec::new();
|
||||
let mut by_provider_id: HashMap<&str, VecDeque<usize>> = HashMap::new();
|
||||
for entry in history.entries() {
|
||||
match &entry.item {
|
||||
Item::ToolCall { call_id, .. } => {
|
||||
by_provider_id
|
||||
.entry(call_id.as_str())
|
||||
.or_default()
|
||||
.push_back(calls.len());
|
||||
calls.push(Some(entry));
|
||||
}
|
||||
Item::ToolResult { call_id, .. } => {
|
||||
if let Some(queue) = by_provider_id.get_mut(call_id.as_str())
|
||||
&& let Some(index) = queue.pop_front()
|
||||
{
|
||||
calls[index] = None;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
calls.into_iter().flatten().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tests/tool_call_identity.rs"]
|
||||
mod tool_call_identity_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -309,7 +309,7 @@ pub struct ToolCallInfo<'a, A = ()> {
|
||||
pub meta: ToolMeta,
|
||||
/// Tool instance (for state access).
|
||||
pub tool: Arc<dyn Tool>,
|
||||
/// Response-local execution context for this call.
|
||||
/// Execution context. The Engine binds durable identity after pre-tool policy.
|
||||
pub context: ToolExecutionContext,
|
||||
}
|
||||
|
||||
@@ -324,7 +324,7 @@ pub struct ToolResultInfo<'a, A = ()> {
|
||||
pub meta: ToolMeta,
|
||||
/// Tool instance (for state access).
|
||||
pub tool: Arc<dyn Tool>,
|
||||
/// Response-local execution context for this call.
|
||||
/// Execution context. The Engine binds durable identity after pre-tool policy.
|
||||
pub context: ToolExecutionContext,
|
||||
}
|
||||
|
||||
@@ -344,6 +344,14 @@ pub struct ToolResultInfo<'a, A = ()> {
|
||||
/// flows, permission checks, and other trusted host adaptation.
|
||||
#[async_trait]
|
||||
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
|
||||
/// Pure projection of a committed ToolCall's host annotation into an opaque,
|
||||
/// durable call identity. Agen neither interprets storage metadata nor derives
|
||||
/// this identity from provider IDs. Early pre-tool policy runs before commit;
|
||||
/// the Engine binds this value after policy, before execution.
|
||||
fn logical_tool_call_id(&self, _annotation: &A) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether side effects may start before [`Self::on_assistant_turn_end`].
|
||||
///
|
||||
/// The conservative default preserves the historical whole-response gate.
|
||||
|
||||
@@ -66,11 +66,15 @@ pub(crate) struct CandidatePart {
|
||||
|
||||
/// Function call.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct FunctionCall {
|
||||
/// Function name.
|
||||
pub name: String,
|
||||
/// Arguments.
|
||||
pub args: Option<serde_json::Value>,
|
||||
/// Partial-argument protocol is not a complete args object.
|
||||
pub partial_args: Option<serde_json::Value>,
|
||||
pub will_continue: Option<bool>,
|
||||
}
|
||||
|
||||
/// Usage metadata.
|
||||
@@ -110,6 +114,14 @@ pub(crate) struct SafetyRating {
|
||||
// Event conversion
|
||||
// ============================================================================
|
||||
|
||||
/// Response-local Gemini block allocation. Parts in separate SSE frames may
|
||||
/// restart their array index, and function names are not call identities.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct GeminiStreamState {
|
||||
next_index: usize,
|
||||
last_text_index: Option<usize>,
|
||||
}
|
||||
|
||||
impl GeminiScheme {
|
||||
/// Convert SSE data to Event.
|
||||
///
|
||||
@@ -120,7 +132,11 @@ impl GeminiScheme {
|
||||
/// * `Ok(Some(Vec<Event>))` - Conversion succeeded.
|
||||
/// * `Ok(None)` - Event ignored.
|
||||
/// * `Err(ClientError)` - Parse error.
|
||||
pub(crate) fn parse_event(&self, data: &str) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
pub(crate) fn parse_event(
|
||||
&self,
|
||||
data: &str,
|
||||
state: &mut GeminiStreamState,
|
||||
) -> Result<Option<Vec<Event>>, ClientError> {
|
||||
// Skip empty or invalid data.
|
||||
if data.is_empty() || data == "[DONE]" {
|
||||
return Ok(None);
|
||||
@@ -144,31 +160,39 @@ impl GeminiScheme {
|
||||
// Process candidates.
|
||||
if let Some(candidates) = response.candidates {
|
||||
for candidate in candidates {
|
||||
let candidate_index = candidate.index.unwrap_or(0);
|
||||
|
||||
if let Some(content) = candidate.content {
|
||||
if let Some(parts) = content.parts {
|
||||
for (part_index, part) in parts.iter().enumerate() {
|
||||
for part in &parts {
|
||||
let index = state.next_index;
|
||||
state.next_index += 1;
|
||||
// Text delta.
|
||||
if let Some(text) = &part.text {
|
||||
if !text.is_empty() {
|
||||
// Gemini sends no explicit BlockStart, so emit TextDelta
|
||||
// directly (Timeline handles the implicit start).
|
||||
events.push(Event::text_delta(part_index, text.clone()));
|
||||
state.last_text_index = Some(index);
|
||||
events.push(Event::text_delta(index, text.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Function call.
|
||||
if let Some(function_call) = &part.function_call {
|
||||
// Function call start.
|
||||
// Gemini usually sends function calls all at once, but may
|
||||
// send partial calls when streaming arguments are enabled.
|
||||
|
||||
// Gemini has no function call IDs, so use the name as the ID.
|
||||
let function_id = format!("call_{}", function_call.name);
|
||||
if function_call.partial_args.is_some()
|
||||
|| function_call.will_continue.is_some()
|
||||
{
|
||||
return Err(ClientError::Api {
|
||||
status: None,
|
||||
code: Some("unsupported_partial_function_call".into()),
|
||||
message: "Gemini partial function arguments require an unsupported streaming protocol".into(),
|
||||
retry_after: None,
|
||||
});
|
||||
}
|
||||
// These synthetic IDs correlate calls within a response;
|
||||
// durable identity still comes from Host history entries.
|
||||
let function_id = format!("call_{index}");
|
||||
|
||||
events.push(Event::BlockStart(BlockStart {
|
||||
index: candidate_index * 10 + part_index, // Composite index.
|
||||
index,
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse {
|
||||
id: function_id,
|
||||
@@ -180,12 +204,13 @@ impl GeminiScheme {
|
||||
if let Some(args) = &function_call.args {
|
||||
let args_str = serde_json::to_string(args).unwrap_or_default();
|
||||
if !args_str.is_empty() && args_str != "null" {
|
||||
events.push(Event::tool_input_delta(
|
||||
candidate_index * 10 + part_index,
|
||||
args_str,
|
||||
));
|
||||
events.push(Event::tool_input_delta(index, args_str));
|
||||
}
|
||||
}
|
||||
// Each args object is complete in this wire part. Close
|
||||
// its collector before the next function call starts;
|
||||
// dispatch still waits for the response-complete gate.
|
||||
events.push(Event::tool_use_stop(index));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,13 +225,14 @@ impl GeminiScheme {
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Text block stop.
|
||||
events.push(Event::BlockStop(BlockStop {
|
||||
index: candidate_index,
|
||||
block_type: BlockType::Text,
|
||||
stop_reason,
|
||||
reasoning: None,
|
||||
}));
|
||||
if let Some(index) = state.last_text_index.take() {
|
||||
events.push(Event::BlockStop(BlockStop {
|
||||
index,
|
||||
block_type: BlockType::Text,
|
||||
stop_reason,
|
||||
reasoning: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -240,7 +266,10 @@ mod tests {
|
||||
let data =
|
||||
r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
let events = scheme
|
||||
.parse_event(data, &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
|
||||
if let Event::BlockDelta(delta) = &events[0] {
|
||||
@@ -260,7 +289,10 @@ mod tests {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Hi"}],"role":"model"},"index":0}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
let events = scheme
|
||||
.parse_event(data, &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Should contain a Usage event.
|
||||
let usage_event = events.iter().find(|e| matches!(e, Event::Usage(_)));
|
||||
@@ -278,7 +310,10 @@ mod tests {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"get_weather","args":{"location":"Tokyo"}}}],"role":"model"},"index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
let events = scheme
|
||||
.parse_event(data, &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Should contain a BlockStart event.
|
||||
let start_event = events.iter().find(|e| matches!(e, Event::BlockStart(_)));
|
||||
@@ -309,7 +344,10 @@ mod tests {
|
||||
let scheme = GeminiScheme::new();
|
||||
let data = r#"{"candidates":[{"content":{"parts":[{"text":"Done"}],"role":"model"},"finishReason":"STOP","index":0}]}"#;
|
||||
|
||||
let events = scheme.parse_event(data).unwrap().unwrap();
|
||||
let events = scheme
|
||||
.parse_event(data, &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Should contain a BlockStop event.
|
||||
let stop_event = events.iter().find(|e| matches!(e, Event::BlockStop(_)));
|
||||
@@ -320,10 +358,85 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_name_calls_have_distinct_ids_and_block_indexes_across_parts_and_frames() {
|
||||
use crate::llm_client::scheme::Scheme;
|
||||
let scheme = GeminiScheme::new();
|
||||
for frames in [
|
||||
vec![
|
||||
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"first"}}},{"functionCall":{"name":"echo","args":{"text":"second"}}}]}}]}"#,
|
||||
],
|
||||
vec![
|
||||
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"first"}}}]}}]}"#,
|
||||
r#"{"candidates":[{"content":{"parts":[{"functionCall":{"name":"echo","args":{"text":"second"}}}]}}]}"#,
|
||||
],
|
||||
] {
|
||||
let mut state = Default::default();
|
||||
let mut events = Vec::new();
|
||||
for frame in frames {
|
||||
events.extend(scheme.parse_sse("", frame, &mut state).unwrap());
|
||||
}
|
||||
let calls: Vec<_> = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::BlockStart(BlockStart {
|
||||
index,
|
||||
metadata: BlockMetadata::ToolUse { id, name },
|
||||
..
|
||||
}) => Some((*index, id.as_str(), name.as_str())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(calls, [(0, "call_0", "echo"), (1, "call_1", "echo")]);
|
||||
let inputs: Vec<_> = events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::BlockDelta(delta) => match &delta.delta {
|
||||
DeltaContent::InputJson(json) => Some((delta.index, json.as_str())),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
inputs,
|
||||
[(0, r#"{"text":"first"}"#), (1, r#"{"text":"second"}"#)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_argument_protocol_is_not_misread_as_an_empty_complete_call() {
|
||||
use crate::llm_client::scheme::Scheme;
|
||||
let scheme = GeminiScheme::new();
|
||||
for call in [
|
||||
r#"{"name":"echo","partialArgs":[{"jsonPath":"$.text","stringValue":"part"}]}"#,
|
||||
r#"{"name":"echo","willContinue":true,"args":{"text":"part"}}"#,
|
||||
r#"{"name":"echo","willContinue":false}"#,
|
||||
] {
|
||||
let data = format!(
|
||||
r#"{{"candidates":[{{"content":{{"parts":[{{"functionCall":{call}}}]}}}}]}}"#
|
||||
);
|
||||
assert!(
|
||||
matches!(scheme.parse_sse("", &data, &mut Default::default()), Err(ClientError::Api { code: Some(code), .. }) if code == "unsupported_partial_function_call")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_data() {
|
||||
let scheme = GeminiScheme::new();
|
||||
assert!(scheme.parse_event("").unwrap().is_none());
|
||||
assert!(scheme.parse_event("[DONE]").unwrap().is_none());
|
||||
assert!(
|
||||
scheme
|
||||
.parse_event("", &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
scheme
|
||||
.parse_event("[DONE]", &mut GeminiStreamState::default())
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +239,8 @@ impl GeminiScheme {
|
||||
let mut contents = Vec::new();
|
||||
let mut pending_model_parts: Vec<GeminiPart> = Vec::new();
|
||||
let mut pending_user_parts: Vec<GeminiPart> = Vec::new();
|
||||
let mut call_names: std::collections::HashMap<&str, std::collections::VecDeque<&str>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
@@ -270,8 +272,12 @@ impl GeminiScheme {
|
||||
}
|
||||
|
||||
Item::ToolCall {
|
||||
name, arguments, ..
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => {
|
||||
call_names.entry(call_id).or_default().push_back(name);
|
||||
// Flush pending user parts first
|
||||
if !pending_user_parts.is_empty() {
|
||||
contents.push(GeminiContent {
|
||||
@@ -309,11 +315,17 @@ impl GeminiScheme {
|
||||
Some(c) => format!("{summary}\n{c}"),
|
||||
None => summary.clone(),
|
||||
};
|
||||
// Gemini responses identify the function by its declaration
|
||||
// name, never by the adapter's response-local correlation ID.
|
||||
let name = call_names
|
||||
.get_mut(call_id.as_str())
|
||||
.and_then(std::collections::VecDeque::pop_front)
|
||||
.unwrap_or(call_id.as_str());
|
||||
pending_user_parts.push(GeminiPart::FunctionResponse {
|
||||
function_response: GeminiFunctionResponse {
|
||||
name: call_id.clone(),
|
||||
name: name.to_string(),
|
||||
response: GeminiFunctionResponseContent {
|
||||
name: call_id.clone(),
|
||||
name: name.to_string(),
|
||||
content: Value::String(text),
|
||||
},
|
||||
},
|
||||
@@ -468,6 +480,36 @@ mod tests {
|
||||
assert_eq!(gemini_req.contents[0].role, "user");
|
||||
assert_eq!(gemini_req.contents[1].role, "model");
|
||||
assert_eq!(gemini_req.contents[2].role, "user");
|
||||
let body = serde_json::to_value(gemini_req).unwrap();
|
||||
assert_eq!(
|
||||
body["contents"][2]["parts"][0]["functionResponse"]["name"],
|
||||
"get_weather"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_results_resolve_function_names_not_synthetic_call_ids() {
|
||||
let scheme = GeminiScheme::new();
|
||||
let request = Request::new()
|
||||
.item(Item::tool_call("call_0", "echo", r#"{"text":"first"}"#))
|
||||
.item(Item::tool_call("call_1", "echo", r#"{"text":"second"}"#))
|
||||
.item(Item::tool_result("call_1", "second"))
|
||||
.item(Item::tool_result("call_0", "first"))
|
||||
.item(Item::tool_call("call_0", "other", "{}"))
|
||||
.item(Item::tool_result("call_0", "later"));
|
||||
let body = serde_json::to_value(scheme.build_request(&request, &cap())).unwrap();
|
||||
let responses: Vec<_> = body["contents"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.flat_map(|content| content["parts"].as_array().unwrap())
|
||||
.filter_map(|part| part.get("functionResponse"))
|
||||
.collect();
|
||||
assert_eq!(responses.len(), 3);
|
||||
for (response, name) in responses.iter().zip(["echo", "echo", "other"]) {
|
||||
assert_eq!(response["name"], name);
|
||||
assert_eq!(response["response"]["name"], name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::llm_client::{
|
||||
use super::GeminiScheme;
|
||||
|
||||
impl Scheme for GeminiScheme {
|
||||
type State = ();
|
||||
type State = super::events::GeminiStreamState;
|
||||
|
||||
fn default_base_url(&self) -> &'static str {
|
||||
"https://generativelanguage.googleapis.com"
|
||||
@@ -38,9 +38,9 @@ impl Scheme for GeminiScheme {
|
||||
&self,
|
||||
_event_type: &str,
|
||||
data: &str,
|
||||
_state: &mut Self::State,
|
||||
state: &mut Self::State,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
Ok(self.parse_event(data)?.unwrap_or_default())
|
||||
Ok(self.parse_event(data, state)?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Engine component tests with a recording tool and scripted host annotations.
|
||||
//! Persistence is represented by the append callback; Server/IPC storage is not
|
||||
//! exercised here. Run with `cargo test -p agen --lib tool_call_identity_tests`.
|
||||
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct NoLlm;
|
||||
|
||||
#[async_trait]
|
||||
impl LlmClient for NoLlm {
|
||||
async fn stream(&self, _: Request) -> Result<ResponseStream, ClientError> {
|
||||
panic!("identity tests must not request a model response")
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
struct HostIdentity {
|
||||
early: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor<String> for HostIdentity {
|
||||
fn logical_tool_call_id(&self, annotation: &String) -> Option<String> {
|
||||
Some(annotation.clone())
|
||||
}
|
||||
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
info: &mut ToolCallInfo<'_, String>,
|
||||
) -> Result<PreToolAction, InterceptorError> {
|
||||
if self.early {
|
||||
assert!(info.history.is_empty());
|
||||
}
|
||||
// Pre-tool hooks may replace the context, but cannot replace persisted identity.
|
||||
info.context.logical_call_id = Some("hook-forged-identity".into());
|
||||
Ok(PreToolAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingTool(Arc<Mutex<Vec<ToolExecutionContext>>>);
|
||||
|
||||
#[async_trait]
|
||||
impl crate::tool::Tool for RecordingTool {
|
||||
async fn execute(
|
||||
&self,
|
||||
_: &str,
|
||||
context: ToolExecutionContext,
|
||||
) -> Result<crate::tool::ToolOutput, ToolError> {
|
||||
self.0.lock().unwrap().push(context);
|
||||
Ok("recorded".to_string().into())
|
||||
}
|
||||
}
|
||||
|
||||
fn engine(
|
||||
early: bool,
|
||||
) -> (
|
||||
Engine<NoLlm, Mutable, String>,
|
||||
Arc<Mutex<Vec<ToolExecutionContext>>>,
|
||||
) {
|
||||
let mut engine = Engine::new_annotated(NoLlm);
|
||||
engine.set_interceptor(HostIdentity { early });
|
||||
engine.max_turns = Some(0);
|
||||
engine.start_logical_run();
|
||||
let observed = Arc::new(Mutex::new(Vec::new()));
|
||||
let tool = Arc::new(RecordingTool(observed.clone()));
|
||||
engine.register_tool(Arc::new(move || {
|
||||
(crate::tool::ToolMeta::new("record"), tool.clone())
|
||||
}));
|
||||
engine.tool_server_handle().flush_pending();
|
||||
(engine, observed)
|
||||
}
|
||||
|
||||
fn entry(item: Item, id: &str) -> HistoryEntry<String> {
|
||||
HistoryEntry::new(item, id.to_string())
|
||||
}
|
||||
|
||||
fn call(id: &str) -> Item {
|
||||
Item::tool_call_json(id, "record", json!({}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn results_answer_only_preceding_occurrences_of_reused_provider_ids() {
|
||||
let history = History::from_entries(vec![
|
||||
entry(
|
||||
Item::tool_result("orphan", "no preceding call"),
|
||||
"orphan-result",
|
||||
),
|
||||
entry(call("reused"), "host-first"),
|
||||
entry(call("sibling"), "host-sibling"),
|
||||
entry(
|
||||
Item::tool_result("sibling", "out of order"),
|
||||
"sibling-result",
|
||||
),
|
||||
entry(Item::tool_result("reused", "answered"), "first-result"),
|
||||
entry(call("reused"), "host-pending"),
|
||||
entry(call("orphan"), "host-after-orphan"),
|
||||
]);
|
||||
let pending = unanswered_tool_call_entries(&history);
|
||||
assert_eq!(
|
||||
pending
|
||||
.iter()
|
||||
.map(|entry| entry.annotation.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["host-pending", "host-after-orphan"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn early_dispatch_binds_identity_only_after_successful_append() {
|
||||
for reject_append in [false, true] {
|
||||
let (mut engine, observed) = engine(true);
|
||||
let mut history = History::new();
|
||||
let mut batch = EarlyToolExecutionBatch::new("live-batch".into(), 0);
|
||||
let result = engine
|
||||
.admit_early_tool_calls(
|
||||
&mut history,
|
||||
&mut |_| {
|
||||
if reject_append {
|
||||
Err("storage unavailable".into())
|
||||
} else {
|
||||
Ok("host-committed-entry".into())
|
||||
}
|
||||
},
|
||||
&mut batch,
|
||||
vec![crate::timeline::CollectedToolCall {
|
||||
call_index: 0,
|
||||
call: ToolCall {
|
||||
id: "provider-call".into(),
|
||||
name: "record".into(),
|
||||
input: json!({}),
|
||||
},
|
||||
}],
|
||||
)
|
||||
.await;
|
||||
if reject_append {
|
||||
assert!(matches!(result, Err(EngineError::HistoryAppend(_))));
|
||||
assert!(history.is_empty());
|
||||
assert!(batch.execution_handles.is_empty());
|
||||
assert!(observed.lock().unwrap().is_empty());
|
||||
} else {
|
||||
assert!(matches!(result, Ok(EarlyToolAdmission::Continue)));
|
||||
let (_, terminal) = batch.futures.next().await.unwrap();
|
||||
assert_eq!(terminal.tool_use_id, "provider-call");
|
||||
let observed = observed.lock().unwrap();
|
||||
assert_eq!(observed.len(), 1);
|
||||
assert_eq!(
|
||||
observed[0].logical_call_id.as_deref(),
|
||||
Some(history.entries()[0].annotation.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
observed[0].logical_call_id.as_deref(),
|
||||
Some("host-committed-entry")
|
||||
);
|
||||
assert_eq!(batch.call_info_map["provider-call"].3, observed[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn normal_dispatch_and_unstarted_restore_use_the_exact_unanswered_annotation() {
|
||||
for restore in [false, true] {
|
||||
let (mut engine, observed) = engine(false);
|
||||
let mut history = History::from_entries(vec![
|
||||
entry(call("reused"), "host-answered"),
|
||||
entry(
|
||||
Item::tool_result("reused", "already completed"),
|
||||
"answered-result",
|
||||
),
|
||||
]);
|
||||
engine
|
||||
.append_history_items(&mut history, [call("reused")], &mut |_| {
|
||||
Ok("host-pending".into())
|
||||
})
|
||||
.unwrap();
|
||||
if restore {
|
||||
let persisted = serde_json::to_string(&history).unwrap();
|
||||
history = serde_json::from_str(&persisted).unwrap();
|
||||
}
|
||||
let (pending, started) = engine.get_unanswered_tool_calls(&history);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert!(started.is_empty());
|
||||
if restore {
|
||||
engine
|
||||
.run_turn_loop(&mut history, &mut |_| Ok("terminal-result".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
} else {
|
||||
let result = engine
|
||||
.execute_tools(&mut history, &mut |_| Ok("terminal-result".into()), pending)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(result, ToolExecutionResult::Completed));
|
||||
}
|
||||
assert_eq!(observed.lock().unwrap().len(), 1);
|
||||
let context = observed.lock().unwrap()[0].clone();
|
||||
assert_eq!(context.call_id, "reused");
|
||||
assert_eq!(context.logical_call_id.as_deref(), Some("host-pending"));
|
||||
// A subsequent resume sees the terminal result, not another live attempt.
|
||||
engine
|
||||
.run_turn_loop(&mut history, &mut |_| Ok("unexpected-append".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(observed.lock().unwrap().len(), 1);
|
||||
assert!(unanswered_tool_call_entries(&history).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn started_restore_with_reused_provider_id_closes_unknown_without_reexecution() {
|
||||
let (mut engine, observed) = engine(false);
|
||||
let mut history = History::from_entries(vec![
|
||||
entry(call("reused"), "host-answered"),
|
||||
entry(
|
||||
Item::tool_result("reused", "already completed"),
|
||||
"answered-result",
|
||||
),
|
||||
entry(
|
||||
call("reused")
|
||||
.with_tool_execution_metadata(0, Some("old-attempt".into()))
|
||||
.with_status(ItemStatus::InProgress),
|
||||
"host-started",
|
||||
),
|
||||
]);
|
||||
let (pending, started) = engine.get_unanswered_tool_calls(&history);
|
||||
assert!(pending.is_empty());
|
||||
assert_eq!(started, ["reused"]);
|
||||
engine
|
||||
.run_turn_loop(&mut history, &mut |_| Ok("unknown-result".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(observed.lock().unwrap().is_empty());
|
||||
assert!(matches!(
|
||||
&history.entries().last().unwrap().item,
|
||||
Item::ToolResult { call_id, disposition: ToolResultDisposition::OutcomeUnknown, .. }
|
||||
if call_id == "reused"
|
||||
));
|
||||
let len = history.len();
|
||||
engine
|
||||
.run_turn_loop(&mut history, &mut |_| Ok("unexpected-append".into()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(history.len(), len);
|
||||
assert!(observed.lock().unwrap().is_empty());
|
||||
}
|
||||
+14
-6
@@ -336,15 +336,16 @@ pub type ToolDefinition = Arc<dyn Fn() -> (ToolMeta, Arc<dyn Tool>) + Send + Syn
|
||||
|
||||
/// Per-call context supplied by the engine when executing a tool call.
|
||||
///
|
||||
/// The context identifies a tool call within one assistant response's tool-call
|
||||
/// batch without imposing any scheduling policy on the engine. Tool
|
||||
/// implementations may use it for response-local ordering, diagnostics, or
|
||||
/// correlation, but it is intentionally not a handle to engine state, history,
|
||||
/// or session mutation.
|
||||
/// Live batch identity and ordering are response-local. An optional opaque host
|
||||
/// identity comes from the committed ToolCall annotation and survives restore.
|
||||
/// Neither is a handle to engine state, history, or session mutation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolExecutionContext {
|
||||
/// Provider/tool-call id for the call being executed.
|
||||
pub call_id: String,
|
||||
/// Opaque host identity from the committed ToolCall annotation, stable across
|
||||
/// restoration. Provider call IDs and live batch IDs are not replay authority.
|
||||
pub logical_call_id: Option<String>,
|
||||
/// Engine-local identity shared by all tool calls from one execution batch.
|
||||
pub batch_id: String,
|
||||
/// Zero-based order of this call in the model-returned tool-call list.
|
||||
@@ -355,11 +356,18 @@ impl ToolExecutionContext {
|
||||
pub fn new(call_id: impl Into<String>, batch_id: impl Into<String>, call_index: usize) -> Self {
|
||||
Self {
|
||||
call_id: call_id.into(),
|
||||
logical_call_id: None,
|
||||
batch_id: batch_id.into(),
|
||||
call_index,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind an identity supplied by the host's persisted-call annotation.
|
||||
pub fn with_logical_call_id(mut self, logical_call_id: impl Into<String>) -> Self {
|
||||
self.logical_call_id = Some(logical_call_id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Identifies one live execution attempt without making the batch id a durable
|
||||
/// replay or idempotency authority.
|
||||
pub fn execution_id(&self) -> String {
|
||||
@@ -572,7 +580,7 @@ pub trait Tool: Send + Sync {
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `input_json` - JSON-formatted arguments generated by LLM
|
||||
/// * `ctx` - response-local call identity and ordering context
|
||||
/// * `ctx` - per-call identity and live execution ordering context
|
||||
///
|
||||
/// # Returns
|
||||
/// A [`ToolOutput`] with summary and optional detailed content.
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
//! Compile the real public macro through a renamed dependency in an isolated consumer.
|
||||
//! Only Cargo artifacts are shared; Cargo checks the fresh consumer on every invocation.
|
||||
|
||||
use std::{fs, path::PathBuf, process::Command};
|
||||
|
||||
struct Fixture(PathBuf);
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
for attempt in 0..1000 {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"api-macros-renamed-dependency-{}-{attempt}",
|
||||
std::process::id()
|
||||
));
|
||||
match fs::create_dir(&path) {
|
||||
Ok(()) => return Self(path),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(error) => panic!("create renamed dependency fixture: {error}"),
|
||||
}
|
||||
}
|
||||
panic!("could not allocate a renamed dependency fixture directory");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Fixture {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macro_expansion_resolves_a_renamed_support_dependency() {
|
||||
let fixture = std::env::temp_dir().join(format!(
|
||||
"api-macros-renamed-dependency-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&fixture);
|
||||
fs::create_dir_all(fixture.join("src")).expect("create fixture source directory");
|
||||
let fixture = Fixture::new();
|
||||
let fixture = &fixture.0;
|
||||
fs::create_dir(fixture.join("src")).expect("create fixture source directory");
|
||||
|
||||
let package_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
let package_path = package_path
|
||||
@@ -49,13 +74,21 @@ pub fn operation_count() -> usize {
|
||||
)
|
||||
.expect("write fixture source");
|
||||
|
||||
// Keep nested Cargo checks off the outer build's target lock. Cargo owns cache
|
||||
// invalidation and locking between concurrent consumers, not a saved
|
||||
// test-success marker.
|
||||
let executable = std::env::current_exe().expect("current test executable");
|
||||
let target_dir = executable
|
||||
.parent()
|
||||
.and_then(|deps| deps.parent())
|
||||
.expect("test executable has a profile directory")
|
||||
.join("compile-tests/api-macros-renamed");
|
||||
let output = Command::new(env!("CARGO"))
|
||||
.args(["check", "--quiet", "--offline"])
|
||||
.current_dir(&fixture)
|
||||
.env("CARGO_TARGET_DIR", fixture.join("target"))
|
||||
.current_dir(fixture)
|
||||
.env("CARGO_TARGET_DIR", target_dir)
|
||||
.output()
|
||||
.expect("run cargo check for renamed dependency fixture");
|
||||
let _ = fs::remove_dir_all(&fixture);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
|
||||
@@ -81,6 +81,16 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
resolved_path: "profiles/job.dcdl",
|
||||
}],
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:subjektiv-journal-recording"),
|
||||
path: "profiles/subjektiv-journal-recording.dcdl",
|
||||
source: include_str!("../../../resources/profiles/subjektiv-journal-recording.dcdl"),
|
||||
description: "Journal recording policy with Host-bound sequential Job tools.",
|
||||
imports: &[BuiltinProfileImport {
|
||||
specifier: "./job.dcdl",
|
||||
resolved_path: "profiles/job.dcdl",
|
||||
}],
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:ticket-worker"),
|
||||
path: "profiles/ticket-worker.dcdl",
|
||||
@@ -98,6 +108,13 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
description: "Code implementation with optional Git/MR review recipe; no universal Ticket conclusion gate.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:async-chat"),
|
||||
path: "profiles/async-chat.dcdl",
|
||||
source: include_str!("../../../resources/profiles/async-chat.dcdl"),
|
||||
description: "Explicitly connected Subject chat with bounded references and asynchronous Worker delegation.",
|
||||
imports: NO_IMPORTS,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
selector: Some("builtin:companion"),
|
||||
path: "profiles/companion.dcdl",
|
||||
@@ -123,7 +140,7 @@ pub const BUILTIN_PROFILE_RESOURCES: &[BuiltinProfileResource] = &[
|
||||
selector: Some("builtin:orchestrator"),
|
||||
path: "profiles/orchestrator.dcdl",
|
||||
source: include_str!("../../../resources/profiles/orchestrator.dcdl"),
|
||||
description: "Intent-led Ticket orchestration with separate guarded MR integration authority.",
|
||||
description: "Outcome-led orchestration, selection of candidate changes, and risk-proportionate validation.",
|
||||
imports: BASE_IMPORT,
|
||||
},
|
||||
BuiltinProfileResource {
|
||||
|
||||
@@ -39,3 +39,22 @@ fn catalog_import_metadata_has_no_cycles() {
|
||||
visit(resource.path, &mut BTreeSet::new());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn async_chat_is_public_and_does_not_inherit_broader_profile_defaults() {
|
||||
let catalog = builtin_profile_catalog_snapshot();
|
||||
let path = &catalog.entrypoints["builtin:async-chat"];
|
||||
let resource = BUILTIN_PROFILE_RESOURCES
|
||||
.iter()
|
||||
.find(|resource| resource.path == path)
|
||||
.unwrap();
|
||||
assert_eq!(resource.selector, Some("builtin:async-chat"));
|
||||
assert!(!resource.description.is_empty());
|
||||
assert!(resource.imports.is_empty());
|
||||
assert!(
|
||||
!catalog
|
||||
.imports
|
||||
.keys()
|
||||
.any(|request| request.starts_with(&format!("{path}\0")))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -908,6 +908,7 @@ mod tests {
|
||||
workdir_attachments: Vec::new(),
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
};
|
||||
|
||||
let response = create_backend_worker_with_client(&target, &create, &api)
|
||||
|
||||
@@ -92,6 +92,20 @@ impl BackendWorkspaceProductClient {
|
||||
.map_err(|error| crate::backend_workspace::server_client_error(&self.api, error))
|
||||
}
|
||||
|
||||
pub fn read_ticket_link_summary(
|
||||
&self,
|
||||
reference: &str,
|
||||
) -> Result<ticket::TicketLinkSummary, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let reference = reference.to_owned();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_link_summary_get(workspace_id, reference)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_workspace_tags(
|
||||
&self,
|
||||
request: &ticket::TagListRequest,
|
||||
@@ -567,6 +581,7 @@ impl BackendWorkspaceProductClient {
|
||||
workdir_attachments: Vec::new(),
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
};
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let response: BrowserCreateWorkerResponse = self.generated(move |client| async move {
|
||||
@@ -601,6 +616,47 @@ impl BackendWorkspaceProductClient {
|
||||
}
|
||||
|
||||
impl TicketBackend for BackendWorkspaceProductClient {
|
||||
fn get_ticket_parent(&self, id: TicketIdOrSlug) -> ticket::Result<ticket::TicketParentLink> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_parent_get(workspace_id, id)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
fn set_ticket_parent(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: ticket::TicketParentSetRequest,
|
||||
) -> ticket::Result<ticket::TicketParentLink> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_parent_set(workspace_id, id, server_api::TicketParentRequest(request))
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
fn query_ticket_children(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: ticket::TicketChildrenRequest,
|
||||
) -> ticket::Result<ticket::TicketChildrenPage> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_children_query(workspace_id, id, server_api::TicketChildrenQuery(request))
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
fn edit_post_with_snapshots(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
|
||||
@@ -4093,3 +4093,52 @@ mod tests {
|
||||
assert_eq!(diagnostics[0].kind, "cycle");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod plugin_preflight_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plugin_closed_schema_matches_shared_native_wasm_projection_and_diagnostic_fixture() {
|
||||
let fixture: serde_json::Value = serde_json::from_str(include_str!(
|
||||
"../../../resources/config-schema/fixtures/plugin-preflight.json"
|
||||
))
|
||||
.unwrap();
|
||||
let bundle = WorkspaceConfigSchemaBundle::compose([ConfigSchemaContribution::new(
|
||||
"plugin-preflight",
|
||||
"plugins",
|
||||
"1",
|
||||
fixture["schema"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let path = VirtualPath::parse("main.dcdl").unwrap();
|
||||
let contract = ToolchainContract::with_schema_bundle(1, vec![path.clone()], 1, bundle);
|
||||
for case in fixture["cases"].as_array().unwrap() {
|
||||
let snapshot = ConfigTreeSnapshot::from_entries([ConfigEntry::new(
|
||||
path.clone(),
|
||||
ConfigContentType::Decodal,
|
||||
case["source"].as_str().unwrap(),
|
||||
)
|
||||
.unwrap()])
|
||||
.unwrap();
|
||||
let result = SnapshotEnvironment::new(snapshot).evaluate_contract(&contract);
|
||||
match case.get("diagnostic") {
|
||||
Some(kind) => {
|
||||
let errors = result.unwrap_err();
|
||||
assert_eq!(errors[0].kind, kind.as_str().unwrap());
|
||||
assert_eq!(errors[0].path.as_str(), "main.dcdl");
|
||||
}
|
||||
None => {
|
||||
let evaluated = result.unwrap();
|
||||
assert_eq!(
|
||||
evaluated.projections[0]
|
||||
.data_json
|
||||
.pointer("/plugins/enabled/echo/config/mode"),
|
||||
Some(&case["mode"])
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "error-contract"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
json-schema = ["dep:schemars"]
|
||||
typescript = ["dep:ts-rs", "json-schema"]
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
iri-string = { version = "0.7.12", default-features = false }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
schemars = { workspace = true, optional = true }
|
||||
ts-rs = { version = "12.0.1", optional = true }
|
||||
|
||||
[[example]]
|
||||
name = "two_domains"
|
||||
required-features = ["test-support"]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Error contract
|
||||
|
||||
This crate owns typed cause/context preservation, current-context disclosure interfaces,
|
||||
bounded codecs and RFC 9457 primitives. It does not own operation outcomes, authorization,
|
||||
retry, cleanup, persistence, HTTP servers, logging sinks or production domain mappings.
|
||||
|
||||
[FailureFacts](crate::FailureFacts) retains the concrete domain cause. A domain derives
|
||||
identity from its variant and implements [ProjectFor](crate::ProjectFor) per audience,
|
||||
selecting typed wire fields and common metadata using the boundary's current authenticated
|
||||
context and resource visibility. Only [project_failure](crate::project_failure) produces an
|
||||
encodable audience marker. Sources and Display are never automatically serialized.
|
||||
|
||||
[codec] distinguishes validated, unsupported and corrupt input. A received value must be
|
||||
reprojected; opaque unsupported payload cannot be blindly forwarded to another audience.
|
||||
[problem] uses API-boundary classification, not inner status forwarding. The `schema` module
|
||||
(with `json-schema`) exposes the same DTOs to existing schema generators. The `test_support`
|
||||
module (with `test-support`) provides two independent sample domains and shared wire fixtures for
|
||||
consumer contract tests, not production authorization adapters.
|
||||
@@ -0,0 +1,49 @@
|
||||
use error_contract::{compatibility::*, problem::*, test_support::*, *};
|
||||
|
||||
fn main() -> Result<(), ContractError> {
|
||||
let access = CurrentAccess {
|
||||
can_view_resource: true,
|
||||
language: Language::English,
|
||||
};
|
||||
let budget = Budget::new(1024 * 1024, 16 * 1024).with_depth(64, 15);
|
||||
let subject = subject_facts();
|
||||
let browser = project_failure::<Browser, _>(&subject, &access)?;
|
||||
let response =
|
||||
to_problem_details(&browser, &WorkerApi, "urn:yoi:occurrence:example-1", budget)?;
|
||||
assert_eq!(response.status, 409); // The inner Subject API would classify this as 403.
|
||||
assert_no_fixture_secrets(response.body());
|
||||
|
||||
let workdir = workdir_facts();
|
||||
let tool = project_failure::<Tool, _>(&workdir, &access)?;
|
||||
let encoded = encode_projected_failure(&tool, budget)?;
|
||||
assert_semantic_roundtrip(encoded.as_bytes(), tool.details(), budget);
|
||||
|
||||
// Ship this reader before switching any existing result writer. Old diagnostic prose is
|
||||
// deliberately not an input to classification. The real domain result remains separate.
|
||||
assert!(matches!(
|
||||
read_failure_attachment::<SubjectDenied>(None, budget),
|
||||
StoredFailureRead::LegacyWithoutFacts
|
||||
));
|
||||
let storage = project_failure::<Storage, _>(&subject, &access)?;
|
||||
let stored = encode_projected_failure(&storage, budget)?;
|
||||
let received = match read_failure_attachment::<SubjectDenied>(Some(stored.as_bytes()), budget) {
|
||||
StoredFailureRead::Structured(DecodeFailure::Known(value)) => value,
|
||||
_ => return Err(ContractError::Invalid),
|
||||
};
|
||||
let current_access = CurrentAccess {
|
||||
can_view_resource: false,
|
||||
language: Language::Japanese,
|
||||
};
|
||||
let reread = project_failure::<Browser, _>(received.facts(), ¤t_access)?;
|
||||
assert_eq!(
|
||||
reread.details().required_operation,
|
||||
RequiredOperation::SessionRecord
|
||||
);
|
||||
assert!(reread.details().subject_ref.is_none());
|
||||
|
||||
let telemetry = project_failure::<Telemetry, _>(&subject, &access)?;
|
||||
let fields = observation_fields(&telemetry);
|
||||
assert_eq!(fields.stage, Some("register"));
|
||||
// Feed fields to the existing boundary's tracing sink; never log the source chain here.
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"version":1,"required_extensions":[],"domain":"subject","reason":"operation_denied","category":"authorization","details_version":1,"details":{"required_operation":"session.record","subject_ref":"subject-1"},"explanation":"Worker creation cannot register the session: session.record is denied.","context":{"operations":[{"operation":"worker.create","stage":"register","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":"correlation-1","supplementary_omitted":false}}
|
||||
@@ -0,0 +1 @@
|
||||
{"version":1,"required_extensions":[],"domain":"workdir","reason":"in_use","category":"conflict","details_version":1,"details":{"operation":"open","workdir_ref":"workdir-1"},"explanation":"Workdir open cannot proceed because the workdir is in use.","context":{"operations":[{"operation":"workdir.open","stage":"prepare","operation_ref":null}],"causes":[],"diagnostics":[],"correlation":"correlation-2","supplementary_omitted":false}}
|
||||
@@ -0,0 +1,296 @@
|
||||
//! Bounded wire handling. A validated receiver value is still not a disclosure capability.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{io::Write, marker::PhantomData};
|
||||
|
||||
use crate::*;
|
||||
|
||||
/// Both the transport limit and the remaining enclosing-envelope budget are mandatory.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Budget {
|
||||
bytes: usize,
|
||||
depth: usize,
|
||||
}
|
||||
impl Budget {
|
||||
pub fn new(transport_limit: usize, remaining_envelope_bytes: usize) -> Self {
|
||||
Self {
|
||||
bytes: transport_limit
|
||||
.min(remaining_envelope_bytes)
|
||||
.min(MAX_FAILURE_BYTES),
|
||||
depth: MAX_JSON_DEPTH,
|
||||
}
|
||||
}
|
||||
/// Use the smaller transport depth and the remaining depth after enclosing wrappers.
|
||||
/// A JSON-string embedding must additionally budget its escaped bytes at that boundary.
|
||||
pub fn with_depth(mut self, transport_depth: usize, remaining_envelope_depth: usize) -> Self {
|
||||
self.depth = self
|
||||
.depth
|
||||
.min(transport_depth)
|
||||
.min(remaining_envelope_depth);
|
||||
self
|
||||
}
|
||||
pub fn bytes(self) -> usize {
|
||||
self.bytes
|
||||
}
|
||||
pub fn depth(self) -> usize {
|
||||
self.depth
|
||||
}
|
||||
}
|
||||
|
||||
/// DTO/schema source only. Deserialization does not produce a ProjectedFailure.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct FailureEnvelope<D> {
|
||||
#[cfg_attr(feature = "json-schema", schemars(range(min = 1)))]
|
||||
pub version: u16,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(max = 8)))]
|
||||
pub required_extensions: Vec<String>,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub domain: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub reason: String,
|
||||
pub category: Category,
|
||||
#[cfg_attr(feature = "json-schema", schemars(range(min = 1)))]
|
||||
pub details_version: u16,
|
||||
pub details: D,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
||||
pub explanation: String,
|
||||
pub context: FailureContext,
|
||||
}
|
||||
|
||||
// A tolerant reader header keeps future category spellings opaque. Public schema/TS authority
|
||||
// stays on FailureEnvelope; only supported identities are deserialized into its typed shape.
|
||||
#[derive(Deserialize)]
|
||||
struct ReaderEnvelope {
|
||||
version: u16,
|
||||
required_extensions: Vec<String>,
|
||||
domain: String,
|
||||
reason: String,
|
||||
category: String,
|
||||
details_version: u16,
|
||||
#[serde(rename = "details")]
|
||||
_details: serde_json::Value,
|
||||
explanation: String,
|
||||
context: FailureContext,
|
||||
}
|
||||
|
||||
pub struct ReceivedFailure<D> {
|
||||
facts: FailureFacts<D>,
|
||||
}
|
||||
impl<D: FailureCause> ReceivedFailure<D> {
|
||||
/// Reprojection with current authorization is required before any encoder can use this.
|
||||
pub fn facts(&self) -> &FailureFacts<D> {
|
||||
&self.facts
|
||||
}
|
||||
}
|
||||
|
||||
/// Unknown bytes are retained opaquely, not serializable or available for blind forwarding.
|
||||
/// Readers may use safe local stage/correlation separately; no reason inference is provided.
|
||||
pub struct UnsupportedFailure {
|
||||
payload: Vec<u8>,
|
||||
}
|
||||
impl UnsupportedFailure {
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
self.payload.len()
|
||||
}
|
||||
}
|
||||
pub enum DecodeFailure<D> {
|
||||
Known(ReceivedFailure<D>),
|
||||
Unsupported(UnsupportedFailure),
|
||||
Corrupt(ContractError),
|
||||
}
|
||||
|
||||
pub fn decode_failure<D: WireDetails>(input: &[u8], budget: Budget) -> DecodeFailure<D> {
|
||||
match decode_checked(input, budget) {
|
||||
Ok(value) => value,
|
||||
Err(error) => DecodeFailure::Corrupt(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn decode_checked<D: WireDetails>(
|
||||
input: &[u8],
|
||||
budget: Budget,
|
||||
) -> Result<DecodeFailure<D>, ContractError> {
|
||||
check_json_bounds(input, budget)?;
|
||||
let raw: ReaderEnvelope = serde_json::from_slice(input).map_err(|_| ContractError::Invalid)?;
|
||||
validate_envelope_common(&raw)?;
|
||||
if raw.version != SCHEMA_VERSION
|
||||
|| !raw.required_extensions.is_empty()
|
||||
|| !D::supports(&raw.domain, &raw.reason, raw.details_version)
|
||||
{
|
||||
return Ok(DecodeFailure::Unsupported(UnsupportedFailure {
|
||||
payload: input.to_vec(),
|
||||
}));
|
||||
}
|
||||
// Deserialize from the original bytes again, not a Value: typed duplicate fields must fail.
|
||||
let typed: FailureEnvelope<D> =
|
||||
serde_json::from_slice(input).map_err(|_| ContractError::InvalidDetails)?;
|
||||
accept_typed_envelope(typed).map(DecodeFailure::Known)
|
||||
}
|
||||
|
||||
pub(crate) fn accept_typed_envelope<D: WireDetails>(
|
||||
typed: FailureEnvelope<D>,
|
||||
) -> Result<ReceivedFailure<D>, ContractError> {
|
||||
let identity = typed.details.identity();
|
||||
if identity.domain != typed.domain
|
||||
|| identity.reason != typed.reason
|
||||
|| identity.category != typed.category
|
||||
|| identity.details_version != typed.details_version
|
||||
{
|
||||
return Err(ContractError::InvalidDetails);
|
||||
}
|
||||
typed.details.validate()?;
|
||||
let facts = FailureFacts::from_received(typed.details, typed.context);
|
||||
validate_failure(&facts)?;
|
||||
Ok(ReceivedFailure { facts })
|
||||
}
|
||||
|
||||
fn validate_envelope_common(raw: &ReaderEnvelope) -> Result<(), ContractError> {
|
||||
if raw.version == 0 || raw.details_version == 0 {
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
validate_identifier(&raw.domain)?;
|
||||
if raw.required_extensions.len() > MAX_DIAGNOSTICS {
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
for extension in &raw.required_extensions {
|
||||
validate_identifier(extension)?;
|
||||
}
|
||||
validate_identifier(&raw.reason)?;
|
||||
validate_identifier(&raw.category)?;
|
||||
validate_explanation(&raw.explanation)?;
|
||||
raw.context.validate()
|
||||
}
|
||||
|
||||
/// Linear lexical depth guard runs before serde allocates any JSON tree. Serde validates grammar.
|
||||
pub(crate) fn check_json_bounds(input: &[u8], budget: Budget) -> Result<(), ContractError> {
|
||||
if input.len() > budget.bytes() {
|
||||
return Err(ContractError::TooLarge);
|
||||
}
|
||||
let (mut depth, mut in_string, mut escaped) = (0usize, false, false);
|
||||
for &byte in input {
|
||||
if in_string {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if byte == b'\\' {
|
||||
escaped = true;
|
||||
} else if byte == b'"' {
|
||||
in_string = false;
|
||||
}
|
||||
} else {
|
||||
match byte {
|
||||
b'"' => in_string = true,
|
||||
b'{' | b'[' => {
|
||||
depth += 1;
|
||||
if depth > budget.depth() {
|
||||
return Err(ContractError::TooDeep);
|
||||
}
|
||||
}
|
||||
b'}' | b']' => {
|
||||
depth = depth.checked_sub(1).ok_or(ContractError::Invalid)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct BoundedWriter {
|
||||
bytes: Vec<u8>,
|
||||
budget: usize,
|
||||
exceeded: bool,
|
||||
}
|
||||
impl Write for BoundedWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
if bytes.len() > self.budget.saturating_sub(self.bytes.len()) {
|
||||
self.exceeded = true;
|
||||
return Err(std::io::Error::other("failure byte budget"));
|
||||
}
|
||||
self.bytes.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
pub(crate) fn encode_bounded<T: Serialize>(
|
||||
value: &T,
|
||||
budget: Budget,
|
||||
) -> Result<Vec<u8>, ContractError> {
|
||||
let mut writer = BoundedWriter {
|
||||
bytes: Vec::new(),
|
||||
budget: budget.bytes(),
|
||||
exceeded: false,
|
||||
};
|
||||
if serde_json::to_writer(&mut writer, value).is_err() {
|
||||
return Err(if writer.exceeded {
|
||||
ContractError::TooLarge
|
||||
} else {
|
||||
ContractError::Encoding
|
||||
});
|
||||
}
|
||||
check_json_bounds(&writer.bytes, budget)?;
|
||||
Ok(writer.bytes)
|
||||
}
|
||||
|
||||
/// The audience remains present until the transport deliberately takes the bytes.
|
||||
pub struct EncodedFailure<A> {
|
||||
bytes: Vec<u8>,
|
||||
audience: PhantomData<A>,
|
||||
}
|
||||
impl<A: Audience> EncodedFailure<A> {
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_projected_failure<A: Audience, D: WireDetails>(
|
||||
failure: &ProjectedFailure<A, D>,
|
||||
budget: Budget,
|
||||
) -> Result<EncodedFailure<A>, ContractError> {
|
||||
let bytes = encode_bounded(&failure.envelope, budget)?;
|
||||
Ok(EncodedFailure {
|
||||
bytes,
|
||||
audience: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Deliberately not a FailureEnvelope: it reports inability to disclose, not a new domain cause.
|
||||
pub const STATIC_FALLBACK: &[u8] =
|
||||
br#"{"failure_unavailable":true,"detail":"Failure details unavailable."}"#;
|
||||
|
||||
pub struct BoundaryEncoding<A> {
|
||||
pub encoded: EncodedFailure<A>,
|
||||
pub conversion_error: Option<ContractError>,
|
||||
/// Kept separate from static payload; the boundary must allowlist it for disclosure.
|
||||
pub correlation: Option<CorrelationRef>,
|
||||
}
|
||||
|
||||
/// No formatting of errors, recursive projection or operation invocation occurs on this path.
|
||||
/// If even the static payload cannot fit, return a boundary error; never truncate required facts.
|
||||
pub fn encode_or_fallback<A: Audience, D: WireDetails>(
|
||||
projection: Result<&ProjectedFailure<A, D>, ContractError>,
|
||||
budget: Budget,
|
||||
correlation: Option<CorrelationRef>,
|
||||
) -> Result<BoundaryEncoding<A>, ContractError> {
|
||||
let result = projection.and_then(|failure| encode_projected_failure(failure, budget));
|
||||
let (encoded, conversion_error) = match result {
|
||||
Ok(encoded) => (encoded, None),
|
||||
Err(error) => {
|
||||
check_json_bounds(STATIC_FALLBACK, budget)?;
|
||||
(
|
||||
EncodedFailure {
|
||||
bytes: STATIC_FALLBACK.to_vec(),
|
||||
audience: PhantomData,
|
||||
},
|
||||
Some(error),
|
||||
)
|
||||
}
|
||||
};
|
||||
Ok(BoundaryEncoding {
|
||||
encoded,
|
||||
conversion_error,
|
||||
correlation,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Reader-first compatibility for optional structured failure attachments.
|
||||
//!
|
||||
//! Existing domain envelopes retain their operation outcome and legacy diagnostics. Ship a
|
||||
//! reader accepting an absent attachment before enabling a writer of a versioned failure.
|
||||
//! Absence is historical text without typed facts, not a fabricated `unknown` domain cause.
|
||||
//! Present-but-corrupt and present-but-unsupported are distinct and must not fall back to
|
||||
//! parsing legacy prose. Readers reproject validated attachments using current visibility.
|
||||
//!
|
||||
//! This module does not migrate DBs or rewrite old archives. The owning store must choose
|
||||
//! its additive field/migration and rollout unit with its consumer. A rollback disables new
|
||||
//! writes first and retains the reader while any new-format results remain. Unknown payload
|
||||
//! retention is local to the original reader; it is never permission to forward to another
|
||||
//! audience. No production writer is enabled merely by depending on this crate.
|
||||
use crate::*;
|
||||
|
||||
pub enum StoredFailureRead<D> {
|
||||
LegacyWithoutFacts,
|
||||
Structured(DecodeFailure<D>),
|
||||
}
|
||||
|
||||
pub fn read_failure_attachment<D: WireDetails>(
|
||||
attachment: Option<&[u8]>,
|
||||
budget: Budget,
|
||||
) -> StoredFailureRead<D> {
|
||||
match attachment {
|
||||
None => StoredFailureRead::LegacyWithoutFacts,
|
||||
Some(bytes) => StoredFailureRead::Structured(decode_failure(bytes, budget)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{error::Error, fmt};
|
||||
|
||||
use crate::*;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub enum Category {
|
||||
Authorization,
|
||||
InvalidInput,
|
||||
Conflict,
|
||||
Dependency,
|
||||
Internal,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// Identity is derived from a domain variant, never supplied independently by a caller.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct FailureIdentity {
|
||||
pub domain: &'static str,
|
||||
pub reason: &'static str,
|
||||
pub category: Category,
|
||||
pub details_version: u16,
|
||||
}
|
||||
|
||||
pub trait FailureCause {
|
||||
fn identity(&self) -> FailureIdentity;
|
||||
/// Domain-owned required fields, length and collection bounds.
|
||||
fn validate(&self) -> Result<(), ContractError>;
|
||||
}
|
||||
|
||||
/// Implement on a typed wire allowlist, not on arbitrary error strings or maps.
|
||||
pub trait WireDetails: FailureCause + Serialize + serde::de::DeserializeOwned {
|
||||
fn supports(domain: &str, reason: &str, details_version: u16) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct OperationContext {
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub operation: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub stage: String,
|
||||
/// A reference, not an idempotency/retry grant. Disclosure remains domain-owned.
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub operation_ref: Option<String>,
|
||||
}
|
||||
|
||||
impl OperationContext {
|
||||
pub fn new(
|
||||
operation: impl Into<String>,
|
||||
stage: impl Into<String>,
|
||||
) -> Result<Self, ContractError> {
|
||||
let context = Self {
|
||||
operation: operation.into(),
|
||||
stage: stage.into(),
|
||||
operation_ref: None,
|
||||
};
|
||||
context.validate()?;
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), ContractError> {
|
||||
validate_identifier(&self.operation)?;
|
||||
validate_identifier(&self.stage)?;
|
||||
if let Some(reference) = &self.operation_ref {
|
||||
validate_identifier(reference)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Correlation is deliberately a distinct field/type from operation identity.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct CorrelationRef(
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))] String,
|
||||
);
|
||||
|
||||
impl CorrelationRef {
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, ContractError> {
|
||||
let value = value.into();
|
||||
validate_identifier(&value)?;
|
||||
Ok(Self(value))
|
||||
}
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Only explicit causal references belong here. Cleanup/audit failures stay in domain results.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct CausalLink {
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub domain: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub reason: String,
|
||||
pub correlation: CorrelationRef,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct Diagnostic {
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 128)))]
|
||||
pub code: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
||||
pub explanation: String,
|
||||
}
|
||||
|
||||
/// Common metadata is untrusted after deserialization. No audience label confers trust.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct FailureContext {
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(max = 8)))]
|
||||
pub operations: Vec<OperationContext>,
|
||||
/// Ordered immediate-to-root chain, at most four references; never recursive payloads.
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(max = 4)))]
|
||||
pub causes: Vec<CausalLink>,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(max = 8)))]
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
pub correlation: Option<CorrelationRef>,
|
||||
pub supplementary_omitted: bool,
|
||||
}
|
||||
|
||||
impl FailureContext {
|
||||
pub fn validate(&self) -> Result<(), ContractError> {
|
||||
if self.operations.len() > MAX_CONTEXTS
|
||||
|| self.causes.len() > MAX_CAUSES
|
||||
|| self.diagnostics.len() > MAX_DIAGNOSTICS
|
||||
{
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
for op in &self.operations {
|
||||
op.validate()?;
|
||||
}
|
||||
for cause in &self.causes {
|
||||
validate_identifier(&cause.domain)?;
|
||||
validate_identifier(&cause.reason)?;
|
||||
validate_identifier(cause.correlation.as_str())?;
|
||||
}
|
||||
for diagnostic in &self.diagnostics {
|
||||
validate_identifier(&diagnostic.code)?;
|
||||
validate_explanation(&diagnostic.explanation)?;
|
||||
}
|
||||
if let Some(correlation) = &self.correlation {
|
||||
validate_identifier(correlation.as_str())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Not serializable: the domain retains its concrete error and source chain.
|
||||
#[derive(Debug)]
|
||||
pub struct FailureFacts<D> {
|
||||
cause: D,
|
||||
context: FailureContext,
|
||||
}
|
||||
|
||||
impl<D: FailureCause> FailureFacts<D> {
|
||||
pub fn new(cause: D) -> Result<Self, ContractError> {
|
||||
let facts = Self {
|
||||
cause,
|
||||
context: FailureContext::default(),
|
||||
};
|
||||
validate_failure(&facts)?;
|
||||
Ok(facts)
|
||||
}
|
||||
pub fn cause(&self) -> &D {
|
||||
&self.cause
|
||||
}
|
||||
pub fn context(&self) -> &FailureContext {
|
||||
&self.context
|
||||
}
|
||||
/// A failed append leaves the original facts intact.
|
||||
pub fn with_context(&mut self, context: OperationContext) -> Result<(), ContractError> {
|
||||
context.validate()?;
|
||||
if self.context.operations.len() == MAX_CONTEXTS {
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
self.context.operations.push(context);
|
||||
Ok(())
|
||||
}
|
||||
pub fn set_correlation(&mut self, correlation: CorrelationRef) -> Result<(), ContractError> {
|
||||
validate_identifier(correlation.as_str())?;
|
||||
self.context.correlation = Some(correlation);
|
||||
Ok(())
|
||||
}
|
||||
pub fn with_causal_link(&mut self, cause: CausalLink) -> Result<(), ContractError> {
|
||||
validate_identifier(&cause.domain)?;
|
||||
validate_identifier(&cause.reason)?;
|
||||
validate_identifier(cause.correlation.as_str())?;
|
||||
if self.context.causes.len() == MAX_CAUSES {
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
self.context.causes.push(cause);
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn from_received(cause: D, context: FailureContext) -> Self {
|
||||
Self { cause, context }
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_failure<D: FailureCause>(facts: &FailureFacts<D>) -> Result<(), ContractError> {
|
||||
let identity = facts.cause.identity();
|
||||
validate_identifier(identity.domain)?;
|
||||
validate_identifier(identity.reason)?;
|
||||
if identity.details_version == 0 {
|
||||
return Err(ContractError::InvalidDetails);
|
||||
}
|
||||
facts.cause.validate()?;
|
||||
facts.context.validate()
|
||||
}
|
||||
|
||||
impl<D: FailureCause> fmt::Display for FailureFacts<D> {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let identity = self.cause.identity();
|
||||
write!(f, "{}.{}", identity.domain, identity.reason)
|
||||
}
|
||||
}
|
||||
impl<D: FailureCause + Error + 'static> Error for FailureFacts<D> {
|
||||
fn source(&self) -> Option<&(dyn Error + 'static)> {
|
||||
Some(&self.cause)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
pub mod codec;
|
||||
pub mod compatibility;
|
||||
pub mod facts;
|
||||
pub mod problem;
|
||||
pub mod projection;
|
||||
#[cfg(feature = "json-schema")]
|
||||
pub mod schema;
|
||||
#[cfg(feature = "test-support")]
|
||||
pub mod test_support;
|
||||
|
||||
pub use codec::{Budget, DecodeFailure, ReceivedFailure, decode_failure, encode_projected_failure};
|
||||
pub use facts::*;
|
||||
pub use projection::*;
|
||||
|
||||
pub const SCHEMA_VERSION: u16 = 1;
|
||||
pub const MAX_FAILURE_BYTES: usize = 32 * 1024;
|
||||
pub const MAX_JSON_DEPTH: usize = 16;
|
||||
pub const MAX_CONTEXTS: usize = 8;
|
||||
pub const MAX_CAUSES: usize = 4;
|
||||
pub const MAX_DIAGNOSTICS: usize = 8;
|
||||
pub const MAX_EXPLANATION_BYTES: usize = 2 * 1024;
|
||||
pub const MAX_IDENTIFIER_BYTES: usize = 128;
|
||||
|
||||
/// Conversion errors describe only the boundary, never the original operation's outcome.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ContractError {
|
||||
#[error("failure exceeds the available byte budget")]
|
||||
TooLarge,
|
||||
#[error("failure exceeds the JSON depth limit")]
|
||||
TooDeep,
|
||||
#[error("invalid failure structure")]
|
||||
Invalid,
|
||||
#[error("invalid typed failure details")]
|
||||
InvalidDetails,
|
||||
#[error("failure projection unavailable")]
|
||||
ProjectionUnavailable,
|
||||
#[error("failure serialization unavailable")]
|
||||
Encoding,
|
||||
}
|
||||
|
||||
pub fn validate_identifier(value: &str) -> Result<(), ContractError> {
|
||||
if value.is_empty() || value.len() > MAX_IDENTIFIER_BYTES || value.chars().any(char::is_control)
|
||||
{
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_explanation(value: &str) -> Result<(), ContractError> {
|
||||
if value.is_empty() || value.len() > MAX_EXPLANATION_BYTES || value.contains('\0') {
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
//! RFC 9457 primitives, without an HTTP server/client dependency or inner-status forwarding.
|
||||
use crate::{
|
||||
codec::{self, FailureEnvelope},
|
||||
*,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const PROBLEM_CONTENT_TYPE: &str = "application/problem+json";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HttpFailureStatus {
|
||||
BadRequest,
|
||||
Unauthorized,
|
||||
Forbidden,
|
||||
NotFound,
|
||||
Conflict,
|
||||
Unprocessable,
|
||||
TooManyRequests,
|
||||
Internal,
|
||||
BadGateway,
|
||||
Unavailable,
|
||||
GatewayTimeout,
|
||||
}
|
||||
impl HttpFailureStatus {
|
||||
pub fn code(self) -> u16 {
|
||||
match self {
|
||||
Self::BadRequest => 400,
|
||||
Self::Unauthorized => 401,
|
||||
Self::Forbidden => 403,
|
||||
Self::NotFound => 404,
|
||||
Self::Conflict => 409,
|
||||
Self::Unprocessable => 422,
|
||||
Self::TooManyRequests => 429,
|
||||
Self::Internal => 500,
|
||||
Self::BadGateway => 502,
|
||||
Self::Unavailable => 503,
|
||||
Self::GatewayTimeout => 504,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The API boundary, not Category or an inner HTTP response, chooses this mapping.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ProblemClass {
|
||||
pub status: HttpFailureStatus,
|
||||
pub type_uri: &'static str,
|
||||
pub title: &'static str,
|
||||
}
|
||||
impl ProblemClass {
|
||||
pub fn authorization_denied() -> Self {
|
||||
Self {
|
||||
status: HttpFailureStatus::Forbidden,
|
||||
type_uri: "urn:yoi:problem:authorization-denied",
|
||||
title: "Authorization denied",
|
||||
}
|
||||
}
|
||||
pub fn invalid_input() -> Self {
|
||||
Self {
|
||||
status: HttpFailureStatus::BadRequest,
|
||||
type_uri: "urn:yoi:problem:invalid-input",
|
||||
title: "Invalid input",
|
||||
}
|
||||
}
|
||||
pub fn conflict() -> Self {
|
||||
Self {
|
||||
status: HttpFailureStatus::Conflict,
|
||||
type_uri: "urn:yoi:problem:conflict",
|
||||
title: "Operation conflict",
|
||||
}
|
||||
}
|
||||
pub fn dependency_failure() -> Self {
|
||||
Self {
|
||||
status: HttpFailureStatus::BadGateway,
|
||||
type_uri: "urn:yoi:problem:dependency",
|
||||
title: "Dependency failure",
|
||||
}
|
||||
}
|
||||
}
|
||||
pub trait ApiBoundary<D> {
|
||||
fn classify(&self, details: &D) -> ProblemClass;
|
||||
}
|
||||
|
||||
/// Untrusted wire DTO. Unknown RFC extensions are ignored; yoi_failure is mandatory here.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct ProblemDetails<D> {
|
||||
#[serde(rename = "type")]
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(max = 2048)))]
|
||||
pub type_uri: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
||||
pub title: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(range(min = 400, max = 599)))]
|
||||
pub status: u16,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(min = 1, max = 2048)))]
|
||||
pub detail: String,
|
||||
#[cfg_attr(feature = "json-schema", schemars(length(max = 2048)))]
|
||||
pub instance: String,
|
||||
pub yoi_failure: FailureEnvelope<D>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ReaderProblem {
|
||||
#[serde(rename = "type")]
|
||||
type_uri: String,
|
||||
title: String,
|
||||
status: u16,
|
||||
detail: String,
|
||||
instance: String,
|
||||
yoi_failure: serde_json::Value,
|
||||
}
|
||||
|
||||
pub struct ProblemResponse {
|
||||
pub status: u16,
|
||||
pub content_type: &'static str,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
impl ProblemResponse {
|
||||
pub fn body(&self) -> &[u8] {
|
||||
&self.body
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_uri(value: &str) -> Result<(), ContractError> {
|
||||
if value.len() > MAX_EXPLANATION_BYTES {
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
// Reuse the RFC 3986 validator already in the transport dependency graph, without its
|
||||
// optional features. Unlike WHATWG URL parsing, borrowed UriReferenceStr validation
|
||||
// neither resolves a base URI nor normalizes malformed input into different text.
|
||||
// Empty references, relative paths, queries and fragments are valid URI-references.
|
||||
iri_string::types::UriReferenceStr::new(value)
|
||||
.map(|_| ())
|
||||
.map_err(|_| ContractError::Invalid)
|
||||
}
|
||||
|
||||
pub fn to_problem_details<D: WireDetails>(
|
||||
projected: &ProjectedFailure<Browser, D>,
|
||||
api_boundary: &impl ApiBoundary<D>,
|
||||
instance: &str,
|
||||
budget: Budget,
|
||||
) -> Result<ProblemResponse, ContractError> {
|
||||
let class = api_boundary.classify(projected.details());
|
||||
validate_uri(class.type_uri)?;
|
||||
validate_uri(instance)?;
|
||||
validate_explanation(class.title)?;
|
||||
// Serialize by reference; no new projection or conversion from arbitrary Display exists.
|
||||
let problem = ProblemDetails {
|
||||
type_uri: class.type_uri.into(),
|
||||
title: class.title.into(),
|
||||
status: class.status.code(),
|
||||
detail: projected.explanation().into(),
|
||||
instance: instance.into(),
|
||||
yoi_failure: FailureEnvelope {
|
||||
version: projected.envelope.version,
|
||||
required_extensions: projected.envelope.required_extensions.clone(),
|
||||
domain: projected.envelope.domain.clone(),
|
||||
reason: projected.envelope.reason.clone(),
|
||||
category: projected.envelope.category,
|
||||
details_version: projected.envelope.details_version,
|
||||
details: &projected.envelope.details,
|
||||
explanation: projected.envelope.explanation.clone(),
|
||||
context: projected.envelope.context.clone(),
|
||||
},
|
||||
};
|
||||
let body = codec::encode_bounded(&problem, budget)?;
|
||||
Ok(ProblemResponse {
|
||||
status: problem.status,
|
||||
content_type: PROBLEM_CONTENT_TYPE,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_problem_details<D: WireDetails>(
|
||||
input: &[u8],
|
||||
actual_status: u16,
|
||||
content_type: &str,
|
||||
api_boundary: &impl ApiBoundary<D>,
|
||||
budget: Budget,
|
||||
) -> DecodeFailure<D> {
|
||||
match decode_problem_checked(input, actual_status, content_type, api_boundary, budget) {
|
||||
Ok(result) => result,
|
||||
Err(error) => DecodeFailure::Corrupt(error),
|
||||
}
|
||||
}
|
||||
fn decode_problem_checked<D: WireDetails>(
|
||||
input: &[u8],
|
||||
actual_status: u16,
|
||||
content_type: &str,
|
||||
api_boundary: &impl ApiBoundary<D>,
|
||||
budget: Budget,
|
||||
) -> Result<DecodeFailure<D>, ContractError> {
|
||||
codec::check_json_bounds(input, budget)?;
|
||||
if !content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.eq_ignore_ascii_case(PROBLEM_CONTENT_TYPE)
|
||||
{
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
let raw: ReaderProblem = serde_json::from_slice(input).map_err(|_| ContractError::Invalid)?;
|
||||
if raw.status != actual_status
|
||||
|| !(400..=599).contains(&raw.status)
|
||||
|| Some(raw.detail.as_str())
|
||||
!= raw
|
||||
.yoi_failure
|
||||
.get("explanation")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
validate_uri(&raw.type_uri)?;
|
||||
validate_uri(&raw.instance)?;
|
||||
validate_explanation(&raw.title)?;
|
||||
let failure_bytes = codec::encode_bounded(&raw.yoi_failure, budget)?;
|
||||
match codec::decode_checked::<D>(&failure_bytes, budget)? {
|
||||
DecodeFailure::Known(_) => {
|
||||
// Use original JSON for typed parsing, preserving duplicate-field rejection.
|
||||
let typed: ProblemDetails<D> =
|
||||
serde_json::from_slice(input).map_err(|_| ContractError::InvalidDetails)?;
|
||||
let class = api_boundary.classify(&typed.yoi_failure.details);
|
||||
if typed.status != class.status.code()
|
||||
|| typed.type_uri != class.type_uri
|
||||
|| typed.title != class.title
|
||||
{
|
||||
return Err(ContractError::Invalid);
|
||||
}
|
||||
codec::accept_typed_envelope(typed.yoi_failure).map(DecodeFailure::Known)
|
||||
}
|
||||
other => Ok(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub const STATIC_PROBLEM_FALLBACK: &[u8] = br#"{"type":"about:blank","title":"Internal Server Error","status":500,"detail":"Failure details unavailable."}"#;
|
||||
/// Static fallback is deliberately outside the domain contract and never formats a failure.
|
||||
pub fn problem_fallback(budget: Budget) -> Result<ProblemResponse, ContractError> {
|
||||
codec::check_json_bounds(STATIC_PROBLEM_FALLBACK, budget)?;
|
||||
Ok(ProblemResponse {
|
||||
status: 500,
|
||||
content_type: PROBLEM_CONTENT_TYPE,
|
||||
body: STATIC_PROBLEM_FALLBACK.to_vec(),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
//! Domain allowlists are the only path from internal facts to encodable values.
|
||||
use std::marker::PhantomData;
|
||||
|
||||
use crate::{codec::FailureEnvelope, *};
|
||||
|
||||
mod sealed {
|
||||
pub trait Sealed {}
|
||||
}
|
||||
pub trait Audience: sealed::Sealed {}
|
||||
macro_rules! audiences {
|
||||
($($name:ident),*) => { $(
|
||||
#[derive(Debug)] pub struct $name;
|
||||
impl sealed::Sealed for $name {}
|
||||
impl Audience for $name {}
|
||||
)* };
|
||||
}
|
||||
audiences!(Browser, Tool, InternalTransfer, Storage, Telemetry);
|
||||
|
||||
/// Domain-owned allowlisted output. Context is selected too; it is not copied implicitly.
|
||||
pub struct Disclosure<D> {
|
||||
pub details: D,
|
||||
pub explanation: String,
|
||||
pub context: FailureContext,
|
||||
}
|
||||
impl<D> Disclosure<D> {
|
||||
pub fn new(details: D, explanation: impl Into<String>) -> Self {
|
||||
Self {
|
||||
details,
|
||||
explanation: explanation.into(),
|
||||
context: FailureContext::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Implement separately for each audience. The boundary supplies *current* authorization
|
||||
/// and resource visibility, including when projecting a previously stored/received value.
|
||||
/// This trait does not authenticate a caller or turn an audience name into authority.
|
||||
pub trait ProjectFor<A: Audience>: FailureCause + Sized {
|
||||
type Authorization: ?Sized;
|
||||
type Details: WireDetails;
|
||||
fn disclose(
|
||||
&self,
|
||||
facts: &FailureFacts<Self>,
|
||||
authorization: &Self::Authorization,
|
||||
) -> Result<Disclosure<Self::Details>, ContractError>;
|
||||
}
|
||||
|
||||
/// No Deserialize/Serialize implementation: only projection creates this trust marker.
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// use error_contract::{Browser, Storage, ProjectedFailure};
|
||||
/// fn public_only<D>(_: &ProjectedFailure<Browser, D>) {}
|
||||
/// fn cannot_relabel<D>(stored: &ProjectedFailure<Storage, D>) { public_only(stored); }
|
||||
/// ```
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// use error_contract::{Browser, ProjectedFailure};
|
||||
/// let _: ProjectedFailure<Browser, String> = serde_json::from_str("{}").unwrap();
|
||||
/// ```
|
||||
///
|
||||
/// ```compile_fail
|
||||
/// use error_contract::{Browser, Budget, ReceivedFailure, WireDetails, encode_projected_failure};
|
||||
/// fn received_is_not_projected<D: WireDetails>(received: &ReceivedFailure<D>) {
|
||||
/// encode_projected_failure::<Browser, D>(received, Budget::new(1024, 1024));
|
||||
/// }
|
||||
/// ```
|
||||
pub struct ProjectedFailure<A, D> {
|
||||
pub(crate) envelope: FailureEnvelope<D>,
|
||||
pub(crate) audience: PhantomData<A>,
|
||||
}
|
||||
impl<A: Audience, D: FailureCause> ProjectedFailure<A, D> {
|
||||
pub fn details(&self) -> &D {
|
||||
&self.envelope.details
|
||||
}
|
||||
pub fn identity(&self) -> FailureIdentity {
|
||||
self.envelope.details.identity()
|
||||
}
|
||||
pub fn explanation(&self) -> &str {
|
||||
&self.envelope.explanation
|
||||
}
|
||||
pub fn context(&self) -> &FailureContext {
|
||||
&self.envelope.context
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project_failure<A: Audience, D: ProjectFor<A>>(
|
||||
facts: &FailureFacts<D>,
|
||||
authorization: &D::Authorization,
|
||||
) -> Result<ProjectedFailure<A, D::Details>, ContractError> {
|
||||
validate_failure(facts)?;
|
||||
let disclosure = facts.cause().disclose(facts, authorization)?;
|
||||
disclosure.details.validate()?;
|
||||
let identity = disclosure.details.identity();
|
||||
// A domain can remove optional details, not silently replace a known cause.
|
||||
if identity != facts.cause().identity() {
|
||||
return Err(ContractError::InvalidDetails);
|
||||
}
|
||||
validate_explanation(&disclosure.explanation)?;
|
||||
disclosure.context.validate()?;
|
||||
Ok(ProjectedFailure {
|
||||
envelope: FailureEnvelope {
|
||||
version: SCHEMA_VERSION,
|
||||
required_extensions: Vec::new(),
|
||||
domain: identity.domain.into(),
|
||||
reason: identity.reason.into(),
|
||||
category: identity.category,
|
||||
details_version: identity.details_version,
|
||||
details: disclosure.details,
|
||||
explanation: disclosure.explanation,
|
||||
context: disclosure.context,
|
||||
},
|
||||
audience: PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compose an allowlisted cause and impact without truncating either required part.
|
||||
/// Domains select the translated fragments; consumers must not classify from this prose.
|
||||
pub fn compose_explanation(cause: &str, impact: &str) -> Result<String, ContractError> {
|
||||
validate_explanation(cause)?;
|
||||
validate_explanation(impact)?;
|
||||
let explanation = format!("{impact}: {cause}");
|
||||
validate_explanation(&explanation)?;
|
||||
Ok(explanation)
|
||||
}
|
||||
|
||||
/// Safe local presentation when no supported cause can be disclosed. This is not a
|
||||
/// replacement domain failure or permission to forward unsupported payload bytes.
|
||||
#[derive(Debug)]
|
||||
pub struct UnavailablePresentation {
|
||||
pub detail: &'static str,
|
||||
pub stage: &'static str,
|
||||
pub correlation: Option<CorrelationRef>,
|
||||
}
|
||||
pub fn unavailable_presentation(
|
||||
stage: &'static str,
|
||||
correlation: Option<CorrelationRef>,
|
||||
) -> Result<UnavailablePresentation, ContractError> {
|
||||
validate_identifier(stage)?;
|
||||
if let Some(reference) = &correlation {
|
||||
validate_identifier(reference.as_str())?;
|
||||
}
|
||||
Ok(UnavailablePresentation {
|
||||
detail: "Failure details unavailable.",
|
||||
stage,
|
||||
correlation,
|
||||
})
|
||||
}
|
||||
|
||||
/// Safe fields for an existing tracing sink. No implicit recording at wrappers.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct ObservationFields<'a> {
|
||||
pub domain: &'static str,
|
||||
pub reason: &'static str,
|
||||
pub operation: Option<&'a str>,
|
||||
pub stage: Option<&'a str>,
|
||||
pub correlation: Option<&'a str>,
|
||||
}
|
||||
pub fn observation_fields<D: FailureCause>(
|
||||
failure: &ProjectedFailure<Telemetry, D>,
|
||||
) -> ObservationFields<'_> {
|
||||
let identity = failure.identity();
|
||||
let operation = failure.context().operations.last();
|
||||
ObservationFields {
|
||||
domain: identity.domain,
|
||||
reason: identity.reason,
|
||||
operation: operation.map(|op| op.operation.as_str()),
|
||||
stage: operation.map(|op| op.stage.as_str()),
|
||||
correlation: failure
|
||||
.context()
|
||||
.correlation
|
||||
.as_ref()
|
||||
.map(CorrelationRef::as_str),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! The wire DTOs are the schema source. Domain details supply their own JsonSchema.
|
||||
//! Byte/depth budgets and cross-field validation remain runtime requirements; JSON Schema
|
||||
//! string lengths count codepoints and cannot replace the codec's UTF-8 byte limits.
|
||||
use crate::{codec::FailureEnvelope, problem::ProblemDetails};
|
||||
|
||||
pub fn failure_schema<D: schemars::JsonSchema>() -> schemars::Schema {
|
||||
schemars::schema_for!(FailureEnvelope<D>)
|
||||
}
|
||||
pub fn problem_schema<D: schemars::JsonSchema>() -> schemars::Schema {
|
||||
schemars::schema_for!(ProblemDetails<D>)
|
||||
}
|
||||
|
||||
/// Existing protocol TS generation consumes this fragment; no second DTO/generator is needed.
|
||||
#[cfg(feature = "typescript")]
|
||||
pub fn typescript_declarations() -> String {
|
||||
use crate::*;
|
||||
use ts_rs::TS;
|
||||
let config = ts_rs::Config::default();
|
||||
[
|
||||
Category::decl(&config),
|
||||
OperationContext::decl(&config),
|
||||
CorrelationRef::decl(&config),
|
||||
CausalLink::decl(&config),
|
||||
Diagnostic::decl(&config),
|
||||
FailureContext::decl(&config),
|
||||
FailureEnvelope::<String>::decl(&config),
|
||||
ProblemDetails::<String>::decl(&config),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|declaration| format!("export {declaration}\n"))
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
//! Shared executable examples, not production authorization/domain adapters.
|
||||
//! The fixture authorization context stands in for a boundary's current authenticated policy
|
||||
//! decision; it must never be reconstructed from a received failure or an audience label.
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const SUBJECT_FIXTURE: &str = include_str!("../fixtures/subject-denied.json");
|
||||
pub const WORKDIR_FIXTURE: &str = include_str!("../fixtures/workdir-in-use.json");
|
||||
pub const FIXTURE_SECRETS: &[&str] = &[
|
||||
"credential-secret",
|
||||
"/private/worktree",
|
||||
"raw-http-body",
|
||||
"internal-source",
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Language {
|
||||
English,
|
||||
Japanese,
|
||||
}
|
||||
/// Fixture policy for subject-1/workdir-1 only; a true visibility decision is not a grant
|
||||
/// to disclose arbitrary resource references supplied by a received wire payload.
|
||||
pub struct CurrentAccess {
|
||||
pub can_view_resource: bool,
|
||||
pub language: Language,
|
||||
}
|
||||
impl CurrentAccess {
|
||||
fn visible_reference(&self, reference: Option<&str>, expected: &str) -> Option<String> {
|
||||
reference
|
||||
.filter(|value| self.can_view_resource && *value == expected)
|
||||
.map(str::to_owned)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub enum RequiredOperation {
|
||||
#[serde(rename = "session.record")]
|
||||
SessionRecord,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct SubjectDenied {
|
||||
pub required_operation: RequiredOperation,
|
||||
pub subject_ref: Option<String>,
|
||||
}
|
||||
impl FailureCause for SubjectDenied {
|
||||
fn identity(&self) -> FailureIdentity {
|
||||
FailureIdentity {
|
||||
domain: "subject",
|
||||
reason: "operation_denied",
|
||||
category: Category::Authorization,
|
||||
details_version: 1,
|
||||
}
|
||||
}
|
||||
fn validate(&self) -> Result<(), ContractError> {
|
||||
if let Some(reference) = &self.subject_ref {
|
||||
validate_identifier(reference)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl WireDetails for SubjectDenied {
|
||||
fn supports(domain: &str, reason: &str, version: u16) -> bool {
|
||||
domain == "subject" && reason == "operation_denied" && version == 1
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub enum WorkdirOperation {
|
||||
Open,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct WorkdirInUse {
|
||||
pub operation: WorkdirOperation,
|
||||
pub workdir_ref: Option<String>,
|
||||
}
|
||||
impl FailureCause for WorkdirInUse {
|
||||
fn identity(&self) -> FailureIdentity {
|
||||
FailureIdentity {
|
||||
domain: "workdir",
|
||||
reason: "in_use",
|
||||
category: Category::Conflict,
|
||||
details_version: 1,
|
||||
}
|
||||
}
|
||||
fn validate(&self) -> Result<(), ContractError> {
|
||||
if let Some(reference) = &self.workdir_ref {
|
||||
validate_identifier(reference)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl WireDetails for WorkdirInUse {
|
||||
fn supports(domain: &str, reason: &str, version: u16) -> bool {
|
||||
domain == "workdir" && reason == "in_use" && version == 1
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("internal-source credential-secret /private/worktree raw-http-body")]
|
||||
pub struct PrivateSource;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("subject rejected operation")]
|
||||
pub struct SubjectError {
|
||||
pub details: SubjectDenied,
|
||||
#[source]
|
||||
pub source: PrivateSource,
|
||||
}
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("workdir in use")]
|
||||
pub struct WorkdirError {
|
||||
pub details: WorkdirInUse,
|
||||
#[source]
|
||||
pub source: PrivateSource,
|
||||
}
|
||||
macro_rules! cause {
|
||||
($name:ty) => {
|
||||
impl FailureCause for $name {
|
||||
fn identity(&self) -> FailureIdentity {
|
||||
self.details.identity()
|
||||
}
|
||||
fn validate(&self) -> Result<(), ContractError> {
|
||||
self.details.validate()
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
cause!(SubjectError);
|
||||
cause!(WorkdirError);
|
||||
|
||||
// The domain selects both details and common metadata. No raw source/Display is inspected.
|
||||
fn allowed_context(context: &FailureContext, access: &CurrentAccess) -> FailureContext {
|
||||
FailureContext {
|
||||
operations: context
|
||||
.operations
|
||||
.iter()
|
||||
.filter_map(|operation| {
|
||||
if !matches!(
|
||||
operation.operation.as_str(),
|
||||
"worker.create" | "workdir.open"
|
||||
) || !matches!(operation.stage.as_str(), "register" | "prepare")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(OperationContext {
|
||||
operation: operation.operation.clone(),
|
||||
stage: operation.stage.clone(),
|
||||
operation_ref: access.visible_reference(
|
||||
operation.operation_ref.as_deref(),
|
||||
"visible-operation-ref",
|
||||
),
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
// A wire correlation is not a local proof. This fixture boundary knows only these
|
||||
// two safe occurrence references; production adapters use their current binding.
|
||||
correlation: context
|
||||
.correlation
|
||||
.as_ref()
|
||||
.filter(|reference| matches!(reference.as_str(), "correlation-1" | "correlation-2"))
|
||||
.cloned(),
|
||||
supplementary_omitted: !context.diagnostics.is_empty()
|
||||
|| !context.causes.is_empty()
|
||||
|| context.supplementary_omitted,
|
||||
..FailureContext::default()
|
||||
}
|
||||
}
|
||||
fn subject_disclosure(
|
||||
details: &SubjectDenied,
|
||||
context: &FailureContext,
|
||||
access: &CurrentAccess,
|
||||
) -> Result<Disclosure<SubjectDenied>, ContractError> {
|
||||
let details = SubjectDenied {
|
||||
required_operation: details.required_operation,
|
||||
subject_ref: access.visible_reference(details.subject_ref.as_deref(), "subject-1"),
|
||||
};
|
||||
let explanation = match access.language {
|
||||
Language::English => compose_explanation(
|
||||
"session.record is denied.",
|
||||
"Worker creation cannot register the session",
|
||||
)?,
|
||||
Language::Japanese => compose_explanation(
|
||||
"session.record が拒否されました。",
|
||||
"Worker作成でSessionを登録できません",
|
||||
)?,
|
||||
};
|
||||
Ok(Disclosure {
|
||||
details,
|
||||
explanation,
|
||||
context: allowed_context(context, access),
|
||||
})
|
||||
}
|
||||
fn workdir_disclosure(
|
||||
details: &WorkdirInUse,
|
||||
context: &FailureContext,
|
||||
access: &CurrentAccess,
|
||||
) -> Disclosure<WorkdirInUse> {
|
||||
let details = WorkdirInUse {
|
||||
operation: details.operation,
|
||||
workdir_ref: access.visible_reference(details.workdir_ref.as_deref(), "workdir-1"),
|
||||
};
|
||||
let explanation = match access.language {
|
||||
Language::English => "Workdir open cannot proceed because the workdir is in use.",
|
||||
Language::Japanese => "Workdirは使用中のため開けません。",
|
||||
};
|
||||
Disclosure {
|
||||
details,
|
||||
explanation: explanation.into(),
|
||||
context: allowed_context(context, access),
|
||||
}
|
||||
}
|
||||
macro_rules! adapters {
|
||||
($($audience:ty),*) => { $(
|
||||
impl ProjectFor<$audience> for SubjectDenied {
|
||||
type Authorization = CurrentAccess; type Details = SubjectDenied;
|
||||
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
|
||||
subject_disclosure(self, facts.context(), access)
|
||||
}
|
||||
}
|
||||
impl ProjectFor<$audience> for WorkdirInUse {
|
||||
type Authorization = CurrentAccess; type Details = WorkdirInUse;
|
||||
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
|
||||
Ok(workdir_disclosure(self, facts.context(), access))
|
||||
}
|
||||
}
|
||||
impl ProjectFor<$audience> for SubjectError {
|
||||
type Authorization = CurrentAccess; type Details = SubjectDenied;
|
||||
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
|
||||
subject_disclosure(&self.details, facts.context(), access)
|
||||
}
|
||||
}
|
||||
impl ProjectFor<$audience> for WorkdirError {
|
||||
type Authorization = CurrentAccess; type Details = WorkdirInUse;
|
||||
fn disclose(&self, facts: &FailureFacts<Self>, access: &CurrentAccess) -> Result<Disclosure<Self::Details>, ContractError> {
|
||||
Ok(workdir_disclosure(&self.details, facts.context(), access))
|
||||
}
|
||||
}
|
||||
)* };
|
||||
}
|
||||
adapters!(Browser, Tool, Storage, InternalTransfer, Telemetry);
|
||||
|
||||
pub fn subject_facts() -> FailureFacts<SubjectError> {
|
||||
let mut facts = FailureFacts::new(SubjectError {
|
||||
details: SubjectDenied {
|
||||
required_operation: RequiredOperation::SessionRecord,
|
||||
subject_ref: Some("subject-1".into()),
|
||||
},
|
||||
source: PrivateSource,
|
||||
})
|
||||
.unwrap();
|
||||
facts
|
||||
.with_context(OperationContext::new("worker.create", "register").unwrap())
|
||||
.unwrap();
|
||||
facts
|
||||
.set_correlation(CorrelationRef::new("correlation-1").unwrap())
|
||||
.unwrap();
|
||||
facts
|
||||
}
|
||||
pub fn workdir_facts() -> FailureFacts<WorkdirError> {
|
||||
let mut facts = FailureFacts::new(WorkdirError {
|
||||
details: WorkdirInUse {
|
||||
operation: WorkdirOperation::Open,
|
||||
workdir_ref: Some("workdir-1".into()),
|
||||
},
|
||||
source: PrivateSource,
|
||||
})
|
||||
.unwrap();
|
||||
facts
|
||||
.with_context(OperationContext::new("workdir.open", "prepare").unwrap())
|
||||
.unwrap();
|
||||
facts
|
||||
.set_correlation(CorrelationRef::new("correlation-2").unwrap())
|
||||
.unwrap();
|
||||
facts
|
||||
}
|
||||
|
||||
/// Inner Subject API vs outer Worker API deliberately choose different HTTP classifications.
|
||||
pub struct SubjectApi;
|
||||
pub struct WorkerApi;
|
||||
pub struct WorkdirApi;
|
||||
impl crate::problem::ApiBoundary<SubjectDenied> for SubjectApi {
|
||||
fn classify(&self, _: &SubjectDenied) -> crate::problem::ProblemClass {
|
||||
crate::problem::ProblemClass::authorization_denied()
|
||||
}
|
||||
}
|
||||
impl crate::problem::ApiBoundary<SubjectDenied> for WorkerApi {
|
||||
fn classify(&self, _: &SubjectDenied) -> crate::problem::ProblemClass {
|
||||
crate::problem::ProblemClass::conflict()
|
||||
}
|
||||
}
|
||||
impl crate::problem::ApiBoundary<WorkdirInUse> for WorkdirApi {
|
||||
fn classify(&self, _: &WorkdirInUse) -> crate::problem::ProblemClass {
|
||||
crate::problem::ProblemClass::conflict()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assert_no_fixture_secrets(bytes: &[u8]) {
|
||||
let text = std::str::from_utf8(bytes).expect("JSON is UTF-8");
|
||||
for secret in FIXTURE_SECRETS {
|
||||
assert!(!text.contains(secret), "secret escaped the allowlist");
|
||||
}
|
||||
}
|
||||
|
||||
/// Check the field allowlist on the same wire fixture used by HTTP/TS/storage consumers.
|
||||
pub fn assert_detail_allowlist(bytes: &[u8], allowed: &[&str]) {
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes).expect("fixture JSON");
|
||||
let details = value["details"]
|
||||
.as_object()
|
||||
.expect("fixture details object");
|
||||
for key in details.keys() {
|
||||
assert!(
|
||||
allowed.contains(&key.as_str()),
|
||||
"unexpected public detail field: {key}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assert_context_preserves_details<D: WireDetails + Clone + PartialEq + std::fmt::Debug>(
|
||||
facts: &mut FailureFacts<D>,
|
||||
context: OperationContext,
|
||||
) {
|
||||
let original = facts.cause().clone();
|
||||
facts
|
||||
.with_context(context)
|
||||
.expect("fixture context fits budget");
|
||||
assert_eq!(facts.cause(), &original);
|
||||
}
|
||||
|
||||
/// Semantic check shared by Rust/HTTP/storage fixture consumers. Compare typed details,
|
||||
/// not translated prose or diagnostic order. Reprojection is intentionally a separate step.
|
||||
pub fn assert_semantic_roundtrip<D: WireDetails + PartialEq + std::fmt::Debug>(
|
||||
bytes: &[u8],
|
||||
expected: &D,
|
||||
budget: Budget,
|
||||
) {
|
||||
match decode_failure::<D>(bytes, budget) {
|
||||
DecodeFailure::Known(received) => assert_eq!(received.facts().cause(), expected),
|
||||
_ => panic!("expected a known validated fixture"),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
test-support = []
|
||||
|
||||
[dependencies]
|
||||
rusqlite.workspace = true
|
||||
thiserror.workspace = true
|
||||
|
||||
@@ -121,13 +121,28 @@ impl FeatureRegistration {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
#[path = "open_observation.rs"]
|
||||
mod open_observation;
|
||||
#[cfg(feature = "test-support")]
|
||||
pub use open_observation::OpenObservation;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RegisteredFeature {
|
||||
manager_id: Uuid,
|
||||
feature_id: String,
|
||||
#[cfg(feature = "test-support")]
|
||||
observation: Arc<open_observation::Counters>,
|
||||
}
|
||||
|
||||
impl RegisteredFeature {
|
||||
/// Instance-local physical-operation counters for fixture assertions. The
|
||||
/// observer retains no database and cannot replace or suppress durable I/O.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn open_observation(&self) -> OpenObservation {
|
||||
self.observation.snapshot()
|
||||
}
|
||||
|
||||
pub fn feature_id(&self) -> &str {
|
||||
&self.feature_id
|
||||
}
|
||||
@@ -219,6 +234,8 @@ impl FeatureStorage {
|
||||
Ok(RegisteredFeature {
|
||||
manager_id: self.inner.id,
|
||||
feature_id,
|
||||
#[cfg(feature = "test-support")]
|
||||
observation: Arc::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -279,14 +296,32 @@ impl FeatureStorage {
|
||||
}
|
||||
|
||||
let feature_dir = self.feature_dir(scope_id);
|
||||
#[cfg(feature = "test-support")]
|
||||
let mut counted_sync = |path: &Path| {
|
||||
registration.observation.sync_attempt();
|
||||
let result = sync(path);
|
||||
if result.is_ok() {
|
||||
registration.observation.sync_success();
|
||||
}
|
||||
result
|
||||
};
|
||||
#[cfg(feature = "test-support")]
|
||||
durable_create_directory(&feature_dir, &mut counted_sync)?;
|
||||
#[cfg(not(feature = "test-support"))]
|
||||
durable_create_directory(&feature_dir, &mut sync)?;
|
||||
let path = feature_dir.join(format!("{}.sqlite", registration.feature_id));
|
||||
#[cfg(feature = "test-support")]
|
||||
registration.observation.open_attempt();
|
||||
let connection = Connection::open_with_flags(
|
||||
path,
|
||||
OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
|
||||
)?;
|
||||
#[cfg(feature = "test-support")]
|
||||
registration.observation.open_success();
|
||||
configure_connection(&connection)?;
|
||||
migrate(&connection, ®istration.feature_id, migrations)?;
|
||||
#[cfg(feature = "test-support")]
|
||||
registration.observation.initialized();
|
||||
let connection = Arc::new(FeatureDatabaseInner {
|
||||
scope_id: scope_id.to_string(),
|
||||
feature_id: registration.feature_id.clone(),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Read-only observations for one registration (and its clones) across its scopes.
|
||||
/// Counts real I/O boundaries, not API opens, and retains no physical database.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OpenObservation {
|
||||
pub physical_open_attempts: u64,
|
||||
pub physical_opens: u64,
|
||||
pub completed_initializations: u64,
|
||||
pub directory_sync_attempts: u64,
|
||||
pub directory_sync_successes: u64,
|
||||
}
|
||||
|
||||
impl OpenObservation {
|
||||
pub fn since(self, before: Self) -> Self {
|
||||
Self {
|
||||
physical_open_attempts: self.physical_open_attempts - before.physical_open_attempts,
|
||||
physical_opens: self.physical_opens - before.physical_opens,
|
||||
completed_initializations: self.completed_initializations
|
||||
- before.completed_initializations,
|
||||
directory_sync_attempts: self.directory_sync_attempts - before.directory_sync_attempts,
|
||||
directory_sync_successes: self.directory_sync_successes
|
||||
- before.directory_sync_successes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct Counters {
|
||||
physical_open_attempts: AtomicU64,
|
||||
physical_opens: AtomicU64,
|
||||
completed_initializations: AtomicU64,
|
||||
directory_sync_attempts: AtomicU64,
|
||||
directory_sync_successes: AtomicU64,
|
||||
}
|
||||
|
||||
impl Counters {
|
||||
pub(super) fn snapshot(&self) -> OpenObservation {
|
||||
OpenObservation {
|
||||
physical_open_attempts: self.physical_open_attempts.load(Ordering::Relaxed),
|
||||
physical_opens: self.physical_opens.load(Ordering::Relaxed),
|
||||
completed_initializations: self.completed_initializations.load(Ordering::Relaxed),
|
||||
directory_sync_attempts: self.directory_sync_attempts.load(Ordering::Relaxed),
|
||||
directory_sync_successes: self.directory_sync_successes.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
pub(super) fn open_attempt(&self) {
|
||||
self.physical_open_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn open_success(&self) {
|
||||
self.physical_opens.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn initialized(&self) {
|
||||
self.completed_initializations
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn sync_attempt(&self) {
|
||||
self.directory_sync_attempts.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
pub(super) fn sync_success(&self) {
|
||||
self.directory_sync_successes
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
@@ -200,6 +200,7 @@ fn run_read_with_limits(
|
||||
total_lines,
|
||||
content_hash: content_hasher.finalize().into(),
|
||||
truncated: end < total_lines || byte_truncated,
|
||||
byte_truncated,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -885,6 +886,34 @@ mod bounded_read_tests {
|
||||
assert!(error.to_string().contains("exceeds provider limit 4096"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_read_distinguishes_utf8_byte_cut_from_complete_line_continuation() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path().canonicalize().unwrap();
|
||||
fs::write(root.join("text"), "雪雪needle\nnext").unwrap();
|
||||
for (offset, max_bytes, bytes, more, cut) in [
|
||||
(0, 5, "雪", true, true),
|
||||
(0, 64, "雪雪needle\n", true, false),
|
||||
(1, 64, "next", false, false),
|
||||
] {
|
||||
let result = run_read_bounded(
|
||||
&root,
|
||||
ReadRequest {
|
||||
path: FsPath::new("text").unwrap(),
|
||||
offset,
|
||||
limit: 1,
|
||||
max_bytes,
|
||||
},
|
||||
&RootAccess(root.clone()),
|
||||
BoundedReadLimits::new(1024, 64).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.bytes, bytes.as_bytes());
|
||||
assert_eq!(result.truncated, more);
|
||||
assert_eq!(result.byte_truncated, cut);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_read_caps_retained_response_independently_of_request() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -907,6 +936,7 @@ mod bounded_read_tests {
|
||||
assert_eq!(result.bytes, b"alpha");
|
||||
assert_eq!(result.total_lines, 3);
|
||||
assert!(result.truncated);
|
||||
assert!(result.byte_truncated);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -204,7 +204,11 @@ pub struct ReadResult {
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))]
|
||||
pub total_lines: usize,
|
||||
pub content_hash: ContentHash,
|
||||
/// More source lines remain, or the selected window exceeded the response byte budget.
|
||||
pub truncated: bool,
|
||||
/// The selected line window was cut by its byte budget, possibly within a line.
|
||||
/// Line-based cursors must not treat such a window as completely scanned.
|
||||
pub byte_truncated: bool,
|
||||
}
|
||||
|
||||
/// Binary-safe byte range read with an optional whole-file version fence.
|
||||
|
||||
@@ -15,11 +15,17 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
serde_ignored = "0.1.14"
|
||||
sha2 = "0.10"
|
||||
crc32fast = "1"
|
||||
wasmparser = "0.244.0"
|
||||
wit-parser = "0.244.0"
|
||||
wit-component = "0.244.0"
|
||||
jsonschema = { version = "0.33.0", default-features = false }
|
||||
secrets = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
wit-component = { version = "0.244.0", features = ["dummy-module"] }
|
||||
serial_test = "3.4.0"
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -58,6 +58,10 @@ pub struct WorkerManifestConfig {
|
||||
/// never starts a local MCP subprocess.
|
||||
#[serde(default)]
|
||||
pub mcp: McpConfig,
|
||||
/// Omission retains the lower layer; an explicit section replaces the entire
|
||||
/// selection list, including an empty list. Never unions Plugin grants.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plugins: Option<crate::PluginProfileConfig>,
|
||||
#[serde(default)]
|
||||
pub compaction: Option<CompactionConfigPartial>,
|
||||
/// First-class web tool opt-in. See [`WebConfig`].
|
||||
@@ -295,6 +299,8 @@ pub struct SubjektivFeatureConfigPartial {
|
||||
#[serde(default)]
|
||||
pub consolidation_tools: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub chat: Option<FeatureFlagConfigPartial>,
|
||||
#[serde(default)]
|
||||
pub extraction: Option<MemoryExtractionProfileConfigPartial>,
|
||||
}
|
||||
|
||||
@@ -303,6 +309,7 @@ impl SubjektivFeatureConfigPartial {
|
||||
Self {
|
||||
enabled: other.enabled.or(self.enabled),
|
||||
consolidation_tools: other.consolidation_tools.or(self.consolidation_tools),
|
||||
chat: merge_option(self.chat, other.chat, FeatureFlagConfigPartial::merge),
|
||||
extraction: merge_option(
|
||||
self.extraction,
|
||||
other.extraction,
|
||||
@@ -554,6 +561,7 @@ impl From<SubjektivFeatureConfigPartial> for ResolvedSubjektivFeatureConfig {
|
||||
profile: SubjektivFeatureProfileConfig {
|
||||
enabled: value.enabled.unwrap_or_default(),
|
||||
consolidation_tools: value.consolidation_tools.unwrap_or_default(),
|
||||
chat: value.chat.map(FeatureFlagConfig::from).unwrap_or_default(),
|
||||
extraction: MemoryExtractionProfileConfig {
|
||||
enabled: extraction.enabled.unwrap_or(true),
|
||||
model: extraction.model,
|
||||
@@ -574,6 +582,7 @@ impl From<ResolvedSubjektivFeatureConfig> for SubjektivFeatureConfigPartial {
|
||||
Self {
|
||||
enabled: Some(value.profile.enabled),
|
||||
consolidation_tools: Some(value.profile.consolidation_tools),
|
||||
chat: Some(value.profile.chat.into()),
|
||||
extraction: Some(MemoryExtractionProfileConfigPartial {
|
||||
enabled: Some(value.profile.extraction.enabled),
|
||||
model: value.profile.extraction.model,
|
||||
@@ -795,11 +804,6 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
|
||||
"unknown field in manifest: memory (removed; configure feature.memory)",
|
||||
));
|
||||
}
|
||||
if value.get("plugins").is_some() {
|
||||
return Err(toml::de::Error::custom(
|
||||
"unknown field in manifest: plugins (dynamic Plugins are not supported)",
|
||||
));
|
||||
}
|
||||
if value
|
||||
.get("feature")
|
||||
.and_then(toml::Value::as_table)
|
||||
@@ -930,6 +934,7 @@ impl WorkerManifestConfig {
|
||||
),
|
||||
feature: self.feature.merge(upper.feature),
|
||||
mcp: merge_mcp_config(self.mcp, upper.mcp),
|
||||
plugins: upper.plugins.or(self.plugins),
|
||||
compaction: merge_option(
|
||||
self.compaction,
|
||||
upper.compaction,
|
||||
@@ -1445,6 +1450,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
||||
permissions,
|
||||
feature: FeatureConfig::from(cfg.feature),
|
||||
mcp: cfg.mcp,
|
||||
plugins: cfg.plugins.unwrap_or_default(),
|
||||
compaction,
|
||||
web: cfg.web,
|
||||
skills: cfg.skills,
|
||||
@@ -1492,6 +1498,7 @@ mod tests {
|
||||
permissions: None,
|
||||
feature: FeatureConfigPartial::default(),
|
||||
mcp: McpConfig::default(),
|
||||
plugins: None,
|
||||
session: None,
|
||||
compaction: None,
|
||||
web: None,
|
||||
@@ -2988,6 +2995,83 @@ worker_max_turns = 7
|
||||
assert_eq!(encoded["enabled"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subjektiv_chat_requires_explicit_opt_in_and_roundtrips() {
|
||||
for subject in [
|
||||
"",
|
||||
"[feature.subjektiv]\nenabled = true\n",
|
||||
"[feature.subjektiv.chat]\n",
|
||||
] {
|
||||
let config = WorkerManifestConfig::from_toml(subject).unwrap();
|
||||
let feature = FeatureConfig::from(config.feature);
|
||||
assert!(!feature.subjektiv.profile.chat.enabled);
|
||||
}
|
||||
assert!(
|
||||
!ResolvedSubjektivFeatureConfig::default()
|
||||
.profile
|
||||
.chat
|
||||
.enabled
|
||||
);
|
||||
let legacy: ResolvedSubjektivFeatureConfig = serde_json::from_value(serde_json::json!({
|
||||
"profile": { "enabled": true }
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(!legacy.profile.chat.enabled);
|
||||
|
||||
for enabled in [true, false] {
|
||||
let config = WorkerManifestConfig::from_toml(&format!(
|
||||
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = {enabled}\n"
|
||||
)).unwrap();
|
||||
let resolved = FeatureConfig::from(config.feature).subjektiv;
|
||||
let json = serde_json::to_value(&resolved).unwrap();
|
||||
assert_eq!(json["profile"]["chat"]["enabled"], enabled);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ResolvedSubjektivFeatureConfig>(json).unwrap(),
|
||||
resolved
|
||||
);
|
||||
assert!(resolved.workspace_settings.is_none());
|
||||
assert!(!resolved.execution_enabled());
|
||||
let partial = SubjektivFeatureConfigPartial::from(resolved.clone());
|
||||
let toml = toml::to_string(&partial).unwrap();
|
||||
assert_eq!(
|
||||
ResolvedSubjektivFeatureConfig::from(
|
||||
toml::from_str::<SubjektivFeatureConfigPartial>(&toml).unwrap()
|
||||
),
|
||||
resolved
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subjektiv_chat_merge_preserves_omissions_and_honors_explicit_disable() {
|
||||
let base = WorkerManifestConfig::from_toml(
|
||||
"[feature.subjektiv]\nenabled = true\n[feature.subjektiv.chat]\nenabled = true\n[feature.subjektiv.extraction]\nthreshold = 321\n"
|
||||
).unwrap();
|
||||
for overlay in [
|
||||
"",
|
||||
"[feature.subjektiv]\nconsolidation_tools = false\n",
|
||||
"[feature.subjektiv.chat]\n",
|
||||
] {
|
||||
let merged = base
|
||||
.clone()
|
||||
.merge(WorkerManifestConfig::from_toml(overlay).unwrap());
|
||||
let feature = FeatureConfig::from(merged.feature);
|
||||
assert!(feature.subjektiv.profile.chat.enabled);
|
||||
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
|
||||
}
|
||||
for enabled in [false, true] {
|
||||
let overlay = WorkerManifestConfig::from_toml(&format!(
|
||||
"[feature.subjektiv.chat]\nenabled = {enabled}\n"
|
||||
))
|
||||
.unwrap();
|
||||
let merged = base.clone().merge(overlay);
|
||||
let feature = FeatureConfig::from(merged.feature);
|
||||
assert_eq!(feature.subjektiv.profile.chat.enabled, enabled);
|
||||
assert!(feature.subjektiv.profile.enabled);
|
||||
assert_eq!(feature.subjektiv.profile.extraction.threshold, Some(321));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_preserves_ref() {
|
||||
let lower = WorkerManifestConfig {
|
||||
|
||||
@@ -7,6 +7,7 @@ mod model;
|
||||
pub mod model_catalog;
|
||||
pub mod paths;
|
||||
pub mod plugin;
|
||||
mod plugin_profile;
|
||||
mod profile;
|
||||
mod scope;
|
||||
|
||||
@@ -24,6 +25,7 @@ pub use model::{
|
||||
AuthRef, ModelCapability, ModelManifest, ReasoningControl, ReasoningEffort, SchemeKind,
|
||||
};
|
||||
pub use paths::user_profiles_path;
|
||||
pub use plugin_profile::{PluginProfileConfig, PluginProfileSelection, PluginTicketDataGrant};
|
||||
pub use profile::{
|
||||
ProfileDiscovery, ProfileError, ProfileExecutionTarget, ProfileManifestSnapshot,
|
||||
ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource,
|
||||
@@ -80,6 +82,11 @@ pub struct WorkerManifest {
|
||||
/// consumer boundary.
|
||||
#[serde(default)]
|
||||
pub mcp: McpConfig,
|
||||
/// Narrowing-only Workspace Plugin selections, not enablement or execution pins.
|
||||
/// Empty selections are omitted to preserve existing no-Plugin snapshots and
|
||||
/// their serialized fingerprint inputs.
|
||||
#[serde(default, skip_serializing_if = "PluginProfileConfig::is_empty")]
|
||||
pub plugins: PluginProfileConfig,
|
||||
#[serde(default)]
|
||||
pub compaction: Option<CompactionConfig>,
|
||||
/// First-class web tools configuration. Network access remains fail-closed
|
||||
@@ -412,6 +419,8 @@ pub struct SubjektivFeatureProfileConfig {
|
||||
pub enabled: bool,
|
||||
/// Explicit consolidation tool policy; never grants Job or subject authority.
|
||||
pub consolidation_tools: bool,
|
||||
/// Opt-in conversation tools; the Host must bind an explicit Subject connection.
|
||||
pub chat: FeatureFlagConfig,
|
||||
pub extraction: MemoryExtractionProfileConfig,
|
||||
}
|
||||
|
||||
@@ -420,6 +429,7 @@ impl Default for SubjektivFeatureProfileConfig {
|
||||
Self {
|
||||
enabled: false,
|
||||
consolidation_tools: false,
|
||||
chat: FeatureFlagConfig::disabled(),
|
||||
extraction: MemoryExtractionProfileConfig::default(),
|
||||
}
|
||||
}
|
||||
@@ -1116,6 +1126,7 @@ impl Default for CompactionConfig {
|
||||
impl WorkerManifest {
|
||||
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
||||
self.profile.is_some()
|
||||
|| !self.plugins.is_empty()
|
||||
|| self.feature.memory.workspace_settings.is_some()
|
||||
|| self.feature.subjektiv.workspace_settings.is_some()
|
||||
}
|
||||
@@ -1595,9 +1606,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
);
|
||||
let error = WorkerManifest::from_toml(&toml).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("dynamic Plugins are not supported"),
|
||||
error.to_string().contains("unknown field `enabled`"),
|
||||
"unexpected error: {error}"
|
||||
);
|
||||
}
|
||||
@@ -1617,7 +1626,7 @@ model_id = "claude-sonnet-4-20250514"
|
||||
}),
|
||||
);
|
||||
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
|
||||
assert!(error.to_string().contains("unknown field `plugins`"));
|
||||
assert!(error.to_string().contains("unknown field `resolved`"));
|
||||
|
||||
let mut nested = base;
|
||||
nested
|
||||
|
||||
@@ -100,21 +100,6 @@ pub fn worker_allocation_path() -> Option<PathBuf> {
|
||||
worker_allocation_path_from_runtime_dir(runtime_dir())
|
||||
}
|
||||
|
||||
/// `<runtime_dir>/<worker_name>/` — per-Worker runtime directory.
|
||||
pub fn worker_runtime_dir(worker_name: &str) -> Option<PathBuf> {
|
||||
worker_runtime_dir_from_runtime_dir(runtime_dir(), worker_name)
|
||||
}
|
||||
|
||||
/// `<runtime_dir>/<worker_name>/sock` — Worker's Unix socket path.
|
||||
///
|
||||
/// The `worker` crate's `RuntimeDir::socket_path()` creates the socket inside the
|
||||
/// Worker process. This function **predicts** the path for external callers that
|
||||
/// know the Worker name, such as the TUI attach flow. Both should return the same
|
||||
/// path.
|
||||
pub fn worker_socket_path(worker_name: &str) -> Option<PathBuf> {
|
||||
worker_socket_path_from_runtime_dir(runtime_dir(), worker_name)
|
||||
}
|
||||
|
||||
// ---- internals --------------------------------------------------------------
|
||||
|
||||
fn resolve_config_dir_from_parts(
|
||||
@@ -204,20 +189,6 @@ fn worker_allocation_path_from_runtime_dir(runtime_dir: Option<PathBuf>) -> Opti
|
||||
Some(runtime_dir?.join("workers.json"))
|
||||
}
|
||||
|
||||
fn worker_runtime_dir_from_runtime_dir(
|
||||
runtime_dir: Option<PathBuf>,
|
||||
worker_name: &str,
|
||||
) -> Option<PathBuf> {
|
||||
Some(runtime_dir?.join(worker_name))
|
||||
}
|
||||
|
||||
fn worker_socket_path_from_runtime_dir(
|
||||
runtime_dir: Option<PathBuf>,
|
||||
worker_name: &str,
|
||||
) -> Option<PathBuf> {
|
||||
Some(worker_runtime_dir_from_runtime_dir(runtime_dir, worker_name)?.join("sock"))
|
||||
}
|
||||
|
||||
/// Treats empty environment values as unset. `std::env::var` distinguishes
|
||||
/// `Ok("")` from `Err(NotPresent)`, but path resolution treats both as unset
|
||||
/// for intuitive behavior.
|
||||
@@ -455,16 +426,8 @@ mod tests {
|
||||
PathBuf::from("/sand/sessions")
|
||||
);
|
||||
assert_eq!(
|
||||
worker_allocation_path_from_runtime_dir(runtime_dir.clone()).unwrap(),
|
||||
worker_allocation_path_from_runtime_dir(runtime_dir).unwrap(),
|
||||
PathBuf::from("/sand/run/workers.json")
|
||||
);
|
||||
assert_eq!(
|
||||
worker_runtime_dir_from_runtime_dir(runtime_dir.clone(), "foo").unwrap(),
|
||||
PathBuf::from("/sand/run/foo")
|
||||
);
|
||||
assert_eq!(
|
||||
worker_socket_path_from_runtime_dir(runtime_dir, "foo").unwrap(),
|
||||
PathBuf::from("/sand/run/foo/sock")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+936
-483
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,656 @@
|
||||
//! Static validation of the exact, supported Plugin Component ABI.
|
||||
//!
|
||||
//! Component metadata is inferred from validated bytes, never trusted from a
|
||||
//! producer's custom sections. This module does not instantiate guest code.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use wit_component::{DecodedWasm, WitPrinter};
|
||||
use wit_parser::{InterfaceId, Resolve, WorldId, WorldItem, WorldKey};
|
||||
|
||||
const PLUGIN_WIT: &str = include_str!("../../../../resources/plugin/wit/yoi-plugin-v2.wit");
|
||||
const HOST_WIT: &str =
|
||||
include_str!("../../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit");
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ComponentMetadata {
|
||||
pub world: String,
|
||||
pub imports: Vec<String>,
|
||||
pub exports: Vec<String>,
|
||||
}
|
||||
|
||||
/// Validates an executable Component against the embedded exact-version world.
|
||||
///
|
||||
/// `required_imports` is a set (duplicates are rejected); it must equal both the
|
||||
/// selected world's imports and the actual Component imports. Returned names are
|
||||
/// fully qualified and sorted. The world name comes from the supported contract,
|
||||
/// not a producer-supplied custom section: executable Components do not encode
|
||||
/// the original WIT world name.
|
||||
pub fn validate_component(
|
||||
bytes: &[u8],
|
||||
world: &str,
|
||||
required_imports: &[String],
|
||||
) -> Result<ComponentMetadata, String> {
|
||||
let world_name = match world {
|
||||
"yoi:plugin/tool-plugin@2.0.0" => "tool-plugin",
|
||||
"yoi:plugin/https-tool-plugin@2.0.0" => "https-tool-plugin",
|
||||
"yoi:plugin/logged-tool-plugin@2.0.0" => "logged-tool-plugin",
|
||||
"yoi:plugin/https-logged-tool-plugin@2.0.0" => "https-logged-tool-plugin",
|
||||
"yoi:plugin/ticket-tool-plugin@2.1.0" => "ticket-tool-plugin",
|
||||
"yoi:plugin/ticket-service-plugin@2.2.0" => "ticket-service-plugin",
|
||||
_ => return Err(format!("unsupported Component world `{world}`")),
|
||||
};
|
||||
if !wasmparser::Parser::is_component(bytes) {
|
||||
return Err("expected a WebAssembly Component, not a core module".into());
|
||||
}
|
||||
wasmparser::Validator::new()
|
||||
.validate_all(bytes)
|
||||
.map_err(|error| format!("invalid WebAssembly Component: {error}"))?;
|
||||
let (actual, actual_world) = match wit_component::decode(bytes)
|
||||
.map_err(|error| format!("cannot decode Component interface: {error:#}"))?
|
||||
{
|
||||
DecodedWasm::Component(resolve, world) => (resolve, world),
|
||||
DecodedWasm::WitPackage(..) => {
|
||||
return Err("expected an executable Component, not a WIT type package".into());
|
||||
}
|
||||
};
|
||||
let (expected, expected_world) = contract(world_name)?;
|
||||
let expected_imports = surface(&expected, expected_world, true)?;
|
||||
let expected_exports = surface(&expected, expected_world, false)?;
|
||||
let actual_imports = surface(&actual, actual_world, true)?;
|
||||
let actual_exports = surface(&actual, actual_world, false)?;
|
||||
let required: BTreeSet<_> = required_imports.iter().cloned().collect();
|
||||
if required.len() != required_imports.len() {
|
||||
return Err("duplicate required Component import in manifest".into());
|
||||
}
|
||||
let imports: Vec<_> = expected_imports.keys().cloned().collect();
|
||||
let exports: Vec<_> = expected_exports.keys().cloned().collect();
|
||||
if required != expected_imports.keys().cloned().collect() {
|
||||
return Err(format!(
|
||||
"manifest required_imports do not match world `{world}`: expected {imports:?}"
|
||||
));
|
||||
}
|
||||
if actual_imports.keys().ne(expected_imports.keys()) {
|
||||
return Err(format!(
|
||||
"Component imports do not match world `{world}`: expected {imports:?}, found {:?}",
|
||||
actual_imports.keys().collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
if actual_exports.keys().ne(expected_exports.keys()) {
|
||||
return Err(format!(
|
||||
"Component exports do not match world `{world}`: expected {exports:?}, found {:?}",
|
||||
actual_exports.keys().collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
|
||||
// Compare all decoded interfaces, including those owning referenced types,
|
||||
// not only function names or the world surface. Printing preserves the
|
||||
// qualified owner of type aliases and resource identity, borrow/own, field
|
||||
// names/order, and nested signatures. Resolve::merge is not an ABI check.
|
||||
let expected_interfaces = named_interfaces(&expected)?;
|
||||
for (name, id) in named_interfaces(&actual)? {
|
||||
let expected_id = expected_interfaces
|
||||
.get(&name)
|
||||
.ok_or_else(|| format!("unsupported Component interface `{name}`"))?;
|
||||
if interface_text(&actual, id)? != interface_text(&expected, *expected_id)? {
|
||||
return Err(format!("Component interface `{name}` has an incorrect ABI"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ComponentMetadata {
|
||||
world: world.to_string(),
|
||||
imports,
|
||||
exports,
|
||||
})
|
||||
}
|
||||
|
||||
fn contract(world_name: &str) -> Result<(Resolve, WorldId), String> {
|
||||
let mut resolve = Resolve::default();
|
||||
resolve
|
||||
.push_str("yoi-host-v2.wit", HOST_WIT)
|
||||
.map_err(|error| format!("invalid embedded Host WIT: {error:#}"))?;
|
||||
let old_package = resolve
|
||||
.push_str("yoi-plugin-v2.wit", PLUGIN_WIT)
|
||||
.map_err(|error| format!("invalid embedded Plugin WIT: {error:#}"))?;
|
||||
let package = if matches!(world_name, "ticket-tool-plugin" | "ticket-service-plugin") {
|
||||
resolve
|
||||
.push_str(
|
||||
"yoi-host-v2.1.wit",
|
||||
include_str!(
|
||||
"../../../../resources/plugin/wit-ticket/deps/yoi-host-v2.1/yoi-host-v2.1.wit"
|
||||
),
|
||||
)
|
||||
.map_err(|e| format!("invalid Ticket Host WIT: {e:#}"))?;
|
||||
resolve
|
||||
.push_str(
|
||||
"yoi-plugin-v2.1.wit",
|
||||
include_str!("../../../../resources/plugin/wit-ticket/yoi-plugin-v2.1.wit"),
|
||||
)
|
||||
.map_err(|e| format!("invalid Ticket Plugin WIT: {e:#}"))?
|
||||
} else {
|
||||
old_package
|
||||
};
|
||||
let package = if world_name == "ticket-service-plugin" {
|
||||
resolve
|
||||
.push_str(
|
||||
"yoi-plugin-v2.2.wit",
|
||||
include_str!("../../../../resources/plugin/wit-service/yoi-plugin-v2.2.wit"),
|
||||
)
|
||||
.map_err(|e| format!("invalid Service WIT: {e:#}"))?
|
||||
} else {
|
||||
package
|
||||
};
|
||||
let world = resolve
|
||||
.select_world(&[package], Some(world_name))
|
||||
.map_err(|error| format!("invalid embedded Plugin world: {error:#}"))?;
|
||||
Ok((resolve, world))
|
||||
}
|
||||
|
||||
fn surface(
|
||||
resolve: &Resolve,
|
||||
world: WorldId,
|
||||
imports: bool,
|
||||
) -> Result<BTreeMap<String, InterfaceId>, String> {
|
||||
let items = if imports {
|
||||
&resolve.worlds[world].imports
|
||||
} else {
|
||||
&resolve.worlds[world].exports
|
||||
};
|
||||
let mut names = BTreeMap::new();
|
||||
for (key, item) in items {
|
||||
let (WorldKey::Interface(key_id), WorldItem::Interface { id, .. }) = (key, item) else {
|
||||
return Err(
|
||||
"Component world contains an unsupported bare function/type or named interface"
|
||||
.into(),
|
||||
);
|
||||
};
|
||||
if key_id != id {
|
||||
return Err("Component interface identity does not match its world key".into());
|
||||
}
|
||||
let name = resolve
|
||||
.id_of(*id)
|
||||
.ok_or_else(|| "Component interface has no exact package identity".to_string())?;
|
||||
if names.insert(name.clone(), *id).is_some() {
|
||||
return Err(format!("duplicate Component interface `{name}`"));
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
fn named_interfaces(resolve: &Resolve) -> Result<BTreeMap<String, InterfaceId>, String> {
|
||||
let mut names = BTreeMap::new();
|
||||
for (id, _) in resolve.interfaces.iter() {
|
||||
let name = resolve
|
||||
.id_of(id)
|
||||
.ok_or_else(|| "Component interface has no exact package identity".to_string())?;
|
||||
if names.insert(name.clone(), id).is_some() {
|
||||
return Err(format!("duplicate Component interface `{name}`"));
|
||||
}
|
||||
}
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
fn interface_text(resolve: &Resolve, id: InterfaceId) -> Result<String, String> {
|
||||
// Declaration order is not ABI; record fields, variant cases, parameters,
|
||||
// and results remain ordered. Sorting maps also canonicalizes use aliases
|
||||
// and resource methods without changing their nominal owners.
|
||||
let mut resolve = resolve.clone();
|
||||
resolve.interfaces[id].types.sort_keys();
|
||||
resolve.interfaces[id].functions.sort_keys();
|
||||
let mut printer = WitPrinter::default();
|
||||
printer.emit_docs(false);
|
||||
printer
|
||||
.print_interface(&resolve, id)
|
||||
.map_err(|error| format!("cannot inspect Component ABI: {error:#}"))?;
|
||||
Ok(printer.output.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wit_component::{ComponentEncoder, StringEncoding, dummy_module, embed_component_metadata};
|
||||
use wit_parser::ManglingAndAbi;
|
||||
|
||||
const PURE: &str = "yoi:plugin/tool-plugin@2.0.0";
|
||||
const HTTPS: &str = "yoi:plugin/https-tool-plugin@2.0.0";
|
||||
const LOGGED: &str = "yoi:plugin/logged-tool-plugin@2.0.0";
|
||||
const HTTPS_LOGGED: &str = "yoi:plugin/https-logged-tool-plugin@2.0.0";
|
||||
|
||||
fn fixture(host: &str, plugin: &str, world_name: &str) -> Vec<u8> {
|
||||
let mut resolve = Resolve::default();
|
||||
resolve.push_str("host.wit", host).unwrap();
|
||||
let package = resolve.push_str("plugin.wit", plugin).unwrap();
|
||||
let world = resolve.select_world(&[package], Some(world_name)).unwrap();
|
||||
encode(&resolve, world)
|
||||
}
|
||||
|
||||
fn encode(resolve: &Resolve, world: WorldId) -> Vec<u8> {
|
||||
let mut module = dummy_module(resolve, world, ManglingAndAbi::Standard32);
|
||||
embed_component_metadata(&mut module, resolve, world, StringEncoding::UTF8).unwrap();
|
||||
ComponentEncoder::default()
|
||||
.module(&module)
|
||||
.unwrap()
|
||||
.validate(true)
|
||||
.encode()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn https_imports() -> Vec<String> {
|
||||
[
|
||||
"yoi:host/connections@2.0.0",
|
||||
"yoi:host/errors@2.0.0",
|
||||
"yoi:host/http@2.0.0",
|
||||
]
|
||||
.map(str::to_string)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn logged_imports(https: bool) -> Vec<String> {
|
||||
let mut imports = if https {
|
||||
https_imports()
|
||||
} else {
|
||||
vec!["yoi:host/errors@2.0.0".into()]
|
||||
};
|
||||
imports.push("yoi:host/logging@2.0.0".into());
|
||||
imports
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_world_components_return_sorted_metadata_without_running_guest() {
|
||||
for (name, world, imports) in [
|
||||
("tool-plugin", PURE, vec![]),
|
||||
("https-tool-plugin", HTTPS, https_imports()),
|
||||
("logged-tool-plugin", LOGGED, logged_imports(false)),
|
||||
(
|
||||
"https-logged-tool-plugin",
|
||||
HTTPS_LOGGED,
|
||||
logged_imports(true),
|
||||
),
|
||||
] {
|
||||
let component = fixture(HOST_WIT, PLUGIN_WIT, name);
|
||||
assert_eq!(
|
||||
validate_component(&component, world, &imports).unwrap(),
|
||||
ComponentMetadata {
|
||||
world: world.into(),
|
||||
imports,
|
||||
exports: vec!["yoi:plugin/tool@2.0.0".into()],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logging_worlds_require_exact_manifest_and_actual_import_sets() {
|
||||
for (name, world, imports) in [
|
||||
("logged-tool-plugin", LOGGED, logged_imports(false)),
|
||||
(
|
||||
"https-logged-tool-plugin",
|
||||
HTTPS_LOGGED,
|
||||
logged_imports(true),
|
||||
),
|
||||
] {
|
||||
let component = fixture(HOST_WIT, PLUGIN_WIT, name);
|
||||
let mut missing = imports.clone();
|
||||
missing.retain(|name| !name.contains("/logging@"));
|
||||
assert!(
|
||||
validate_component(&component, world, &missing)
|
||||
.unwrap_err()
|
||||
.contains("manifest required_imports")
|
||||
);
|
||||
let plugin = PLUGIN_WIT.replace("import yoi:host/logging@2.0.0;", "");
|
||||
let component = fixture(HOST_WIT, &plugin, name);
|
||||
assert!(
|
||||
validate_component(&component, world, &imports)
|
||||
.unwrap_err()
|
||||
.contains("Component imports")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logging_world_and_interface_versions_are_exact() {
|
||||
let logging = HOST_WIT
|
||||
.split_once("interface logging {")
|
||||
.unwrap()
|
||||
.1
|
||||
.replace(
|
||||
"use errors.{host-error};",
|
||||
"use yoi:host/errors@2.0.0.{host-error};",
|
||||
);
|
||||
for (name, world, imports) in [
|
||||
("logged-tool-plugin", LOGGED, logged_imports(false)),
|
||||
(
|
||||
"https-logged-tool-plugin",
|
||||
HTTPS_LOGGED,
|
||||
logged_imports(true),
|
||||
),
|
||||
] {
|
||||
let supported = fixture(HOST_WIT, PLUGIN_WIT, name);
|
||||
for version in ["1.0.0", "2.0.1", "9.0.0"] {
|
||||
let unsupported_world = world.replace("@2.0.0", &format!("@{version}"));
|
||||
assert!(
|
||||
validate_component(&supported, &unsupported_world, &imports)
|
||||
.unwrap_err()
|
||||
.contains("unsupported Component world")
|
||||
);
|
||||
let host = format!(
|
||||
"{HOST_WIT}\npackage yoi:host@{version} {{ interface logging {{ {logging} }}"
|
||||
);
|
||||
let plugin = PLUGIN_WIT.replace("logging@2.0.0", &format!("logging@{version}"));
|
||||
let component = fixture(&host, &plugin, name);
|
||||
assert!(
|
||||
validate_component(&component, world, &imports)
|
||||
.unwrap_err()
|
||||
.contains("Component imports")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_logging_nested_payload_and_signature_changes_are_rejected() {
|
||||
for (from, to) in [
|
||||
(
|
||||
"trace, debug, info, warn, error",
|
||||
"debug, trace, info, warn, error",
|
||||
),
|
||||
("number(f64)", "number(f32)"),
|
||||
("signed(s64)", "signed(u64)"),
|
||||
("value: scalar", "value: string"),
|
||||
("fields: list<field>", "fields: option<field>"),
|
||||
("event-code: string", "event-name: string"),
|
||||
(
|
||||
"message: string,\n fields:",
|
||||
"message: string, extra: bool,\n fields:",
|
||||
),
|
||||
("log: func(input: entry)", "log: func(input: string)"),
|
||||
(
|
||||
"log: func(input: entry) -> result<_, host-error>",
|
||||
"log: func(input: entry) -> result<string, host-error>",
|
||||
),
|
||||
("log: func", "extra: func(); log: func"),
|
||||
] {
|
||||
assert!(HOST_WIT.contains(from));
|
||||
let host = HOST_WIT.replace(from, to);
|
||||
for (name, world, imports) in [
|
||||
("logged-tool-plugin", LOGGED, logged_imports(false)),
|
||||
(
|
||||
"https-logged-tool-plugin",
|
||||
HTTPS_LOGGED,
|
||||
logged_imports(true),
|
||||
),
|
||||
] {
|
||||
let component = fixture(&host, PLUGIN_WIT, name);
|
||||
let error = validate_component(&component, world, &imports).unwrap_err();
|
||||
assert!(
|
||||
error.contains("yoi:host/logging@2.0.0") && error.contains("incorrect ABI"),
|
||||
"mutation: {from} -> {to}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validated_metadata_has_serializable_public_fields() {
|
||||
let component = fixture(HOST_WIT, PLUGIN_WIT, "logged-tool-plugin");
|
||||
let metadata = validate_component(&component, LOGGED, &logged_imports(false)).unwrap();
|
||||
let json = serde_json::to_value(&metadata).unwrap();
|
||||
assert_eq!(
|
||||
json,
|
||||
serde_json::json!({
|
||||
"world": LOGGED,
|
||||
"imports": ["yoi:host/errors@2.0.0", "yoi:host/logging@2.0.0"],
|
||||
"exports": ["yoi:plugin/tool@2.0.0"],
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<ComponentMetadata>(json).unwrap(),
|
||||
metadata
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_unknown_and_wrong_manifest_worlds_are_rejected() {
|
||||
let component = fixture(HOST_WIT, PLUGIN_WIT, "tool-plugin");
|
||||
for world in [
|
||||
"yoi:plugin/tool-plugin@1.0.0",
|
||||
"yoi:plugin/tool-plugin@2.0.1",
|
||||
] {
|
||||
assert!(
|
||||
validate_component(&component, world, &[])
|
||||
.unwrap_err()
|
||||
.contains("unsupported Component world")
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
validate_component(&component, HTTPS, &https_imports())
|
||||
.unwrap_err()
|
||||
.contains("Component imports")
|
||||
);
|
||||
for version in ["1.0.0", "9.0.0"] {
|
||||
let changed = PLUGIN_WIT.replace("2.0.0", version);
|
||||
let changed_host = HOST_WIT.replace("2.0.0", version);
|
||||
let component = fixture(&changed_host, &changed, "tool-plugin");
|
||||
assert!(
|
||||
validate_component(&component, PURE, &[])
|
||||
.unwrap_err()
|
||||
.contains("Component exports")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_host_interface_versions_are_not_semver_compatible() {
|
||||
for version in ["1.0.0", "2.0.1", "9.0.0"] {
|
||||
let host = HOST_WIT.replace("@2.0.0", &format!("@{version}"));
|
||||
let plugin = PLUGIN_WIT
|
||||
.replace("errors@2.0.0", &format!("errors@{version}"))
|
||||
.replace("connections@2.0.0", &format!("connections@{version}"))
|
||||
.replace("http@2.0.0", &format!("http@{version}"))
|
||||
.replace("logging@2.0.0", &format!("logging@{version}"));
|
||||
let component = fixture(&host, &plugin, "https-tool-plugin");
|
||||
assert!(
|
||||
validate_component(&component, HTTPS, &https_imports())
|
||||
.unwrap_err()
|
||||
.contains("Component imports")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manifest_import_set_must_equal_actual_and_supported_world() {
|
||||
let component = fixture(HOST_WIT, PLUGIN_WIT, "https-tool-plugin");
|
||||
let mut reordered = https_imports();
|
||||
reordered.reverse();
|
||||
assert!(validate_component(&component, HTTPS, &reordered).is_ok());
|
||||
let mut duplicate = https_imports();
|
||||
duplicate.push(duplicate[0].clone());
|
||||
for imports in [
|
||||
vec![],
|
||||
vec!["wasi:filesystem/types@0.2.0".into()],
|
||||
duplicate,
|
||||
] {
|
||||
assert!(validate_component(&component, HTTPS, &imports).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unadvertised_imports_and_extra_exports_are_rejected() {
|
||||
for (from, to) in [
|
||||
("export tool;", ""),
|
||||
(
|
||||
"world tool-plugin {",
|
||||
"world tool-plugin { import surprise: func();",
|
||||
),
|
||||
(
|
||||
"world tool-plugin {",
|
||||
"world tool-plugin { export surprise: func();",
|
||||
),
|
||||
(
|
||||
"world tool-plugin {",
|
||||
"world tool-plugin { import yoi:host/logging@2.0.0;",
|
||||
),
|
||||
] {
|
||||
let plugin = PLUGIN_WIT.replace(from, to);
|
||||
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
|
||||
assert!(validate_component(&component, PURE, &[]).is_err());
|
||||
}
|
||||
let plugin = PLUGIN_WIT.replace(
|
||||
"world tool-plugin {",
|
||||
"world tool-plugin { import wasi:unpublished/io@0.2.0;",
|
||||
);
|
||||
let host = format!(
|
||||
"{HOST_WIT}\npackage wasi:unpublished@0.2.0 {{ interface io {{ read: func(); }} }}"
|
||||
);
|
||||
let component = fixture(&host, &plugin, "tool-plugin");
|
||||
assert!(validate_component(&component, PURE, &[]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_interface_name_with_changed_nested_signature_is_rejected() {
|
||||
for (from, to) in [
|
||||
("remaining-ms: u32", "remaining-ms: u64"),
|
||||
("remaining-ms: u32", "time-left: u32"),
|
||||
("content: option<string>", "content: list<string>"),
|
||||
("summary: string", "summary: string, extra: bool"),
|
||||
("input: invocation", "input: output"),
|
||||
("result<output, tool-error>", "result<tool-error, output>"),
|
||||
("invoke: func", "extra: func(); invoke: func"),
|
||||
] {
|
||||
assert!(PLUGIN_WIT.contains(from));
|
||||
let plugin = PLUGIN_WIT.replace(from, to);
|
||||
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
|
||||
assert!(
|
||||
validate_component(&component, PURE, &[])
|
||||
.unwrap_err()
|
||||
.contains("incorrect ABI"),
|
||||
"mutation: {from} -> {to}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_resource_identity_ownership_and_nested_records_are_exact() {
|
||||
for (from, to) in [
|
||||
("borrow<connection>", "own<connection>"),
|
||||
("use connections.{connection};", "resource connection;"),
|
||||
("status: u16", "status: u32"),
|
||||
("value: string", "value: list<u8>"),
|
||||
(
|
||||
"resource connection;",
|
||||
"resource connection; resource other;",
|
||||
),
|
||||
("target: borrow<connection>", "target: borrow<other>"),
|
||||
("diagnostic-id: string", "diagnostic-id: u64"),
|
||||
] {
|
||||
let mut host = HOST_WIT.replace(from, to);
|
||||
if to == "target: borrow<other>" {
|
||||
host = host.replace("interface http {", "interface http { resource other;");
|
||||
}
|
||||
assert_ne!(host, HOST_WIT);
|
||||
let component = fixture(&host, PLUGIN_WIT, "https-tool-plugin");
|
||||
assert!(
|
||||
validate_component(&component, HTTPS, &https_imports())
|
||||
.unwrap_err()
|
||||
.contains("incorrect ABI"),
|
||||
"mutation: {from} -> {to}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_declaration_order_is_not_part_of_the_abi() {
|
||||
let (mut resolve, world) = contract("https-tool-plugin").unwrap();
|
||||
for (_, interface) in resolve.interfaces.iter_mut() {
|
||||
interface.types.reverse();
|
||||
interface.functions.reverse();
|
||||
}
|
||||
let component = encode(&resolve, world);
|
||||
assert!(validate_component(&component, HTTPS, &https_imports()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn docs_are_not_part_of_the_abi() {
|
||||
let plugin = PLUGIN_WIT.replace(
|
||||
"interface tool {",
|
||||
"/// Different documentation.\ninterface tool {",
|
||||
);
|
||||
let component = fixture(HOST_WIT, &plugin, "tool-plugin");
|
||||
assert!(validate_component(&component, PURE, &[]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_module_wit_type_package_and_invalid_component_are_rejected() {
|
||||
let (resolve, world) = contract("tool-plugin").unwrap();
|
||||
let module = dummy_module(&resolve, world, ManglingAndAbi::Standard32);
|
||||
assert!(
|
||||
validate_component(&module, PURE, &[])
|
||||
.unwrap_err()
|
||||
.contains("core module")
|
||||
);
|
||||
let package = resolve.worlds[world].package.unwrap();
|
||||
let types = wit_component::encode(&resolve, package).unwrap();
|
||||
assert!(
|
||||
validate_component(&types, PURE, &[])
|
||||
.unwrap_err()
|
||||
.contains("WIT type package")
|
||||
);
|
||||
let mut component = encode(&resolve, world);
|
||||
component.pop();
|
||||
assert!(validate_component(&component, PURE, &[]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ticket_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn additive_ticket_world_cannot_be_claimed_as_an_old_component_world() {
|
||||
let (resolve, world) = contract("ticket-tool-plugin").unwrap();
|
||||
let mut core =
|
||||
wit_component::dummy_module(&resolve, world, wit_parser::ManglingAndAbi::Standard32);
|
||||
wit_component::embed_component_metadata(
|
||||
&mut core,
|
||||
&resolve,
|
||||
world,
|
||||
wit_component::StringEncoding::UTF8,
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = wit_component::ComponentEncoder::default()
|
||||
.module(&core)
|
||||
.unwrap()
|
||||
.validate(true)
|
||||
.encode()
|
||||
.unwrap();
|
||||
let imports: Vec<_> = surface(&resolve, world, true)
|
||||
.unwrap()
|
||||
.into_keys()
|
||||
.collect();
|
||||
let checked = validate_component(
|
||||
&bytes,
|
||||
crate::plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD,
|
||||
&imports,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(checked.imports.contains(&"yoi:host/objects@2.1.0".into()));
|
||||
assert!(
|
||||
checked
|
||||
.imports
|
||||
.contains(&"yoi:host/ticket-read@2.1.0".into())
|
||||
);
|
||||
assert!(
|
||||
checked
|
||||
.imports
|
||||
.contains(&"yoi:host/extension-data@2.1.0".into())
|
||||
);
|
||||
assert_eq!(checked.exports, ["yoi:plugin/tool@2.0.0"]);
|
||||
assert!(
|
||||
validate_component(
|
||||
&bytes,
|
||||
crate::plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD,
|
||||
&imports
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,654 @@
|
||||
//! Offline schema admission shared by package consumers. No guest, DCDL evaluator,
|
||||
//! import loader, or ambient JSON Schema retriever participates in validation.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use decodal::{Ast, Expr, ExprId, Literal, SyntaxTokenKind as Token};
|
||||
use serde_json::Value;
|
||||
|
||||
const MAX_SCHEMA_BYTES: usize = 64 * 1024;
|
||||
const MAX_DEPTH: usize = 32;
|
||||
const MAX_NODES: usize = 4096;
|
||||
const TOOL_DIALECT: &str = "https://json-schema.org/draft/2020-12/schema";
|
||||
|
||||
/// Admit a bounded, offline JSON Schema 2020-12 document (the default dialect).
|
||||
///
|
||||
/// Unknown keywords/dialects, resource IDs, dynamic/anchor/external references,
|
||||
/// dangling references and recursive reference graphs are unsupported and rejected.
|
||||
/// Local JSON Pointer references must target schema positions, not annotation data.
|
||||
/// This checks the schema, not a Tool payload; callers must still validate payloads.
|
||||
pub fn validate_tool_schema(schema: &Value) -> Result<(), String> {
|
||||
bound_json(schema)?;
|
||||
let mut graph = BTreeMap::new();
|
||||
inspect_tool_schema(schema, "", &mut graph)?;
|
||||
let mut complete = BTreeMap::new();
|
||||
for path in graph.keys() {
|
||||
check_reference_graph(path, &graph, &mut BTreeSet::new(), &mut complete, 0)?;
|
||||
}
|
||||
// Use the fixed built-in meta-schema, never schema-directed dialect retrieval.
|
||||
jsonschema::draft202012::meta::validate(schema)
|
||||
.map_err(|error| format!("invalid Tool JSON Schema: {error}"))?;
|
||||
// The compiler normally skips unreferenced $defs. Compile every admitted
|
||||
// schema position through one in-memory resource, so invalid regexes/formats
|
||||
// cannot hide in an unused definition. These URNs are generated, not input.
|
||||
const RESOURCE: &str = "urn:yoi:plugin:tool-schema";
|
||||
let resource = jsonschema::Resource::from_contents(schema.clone())
|
||||
.map_err(|error| format!("invalid Tool JSON Schema resource: {error}"))?;
|
||||
let all_schemas = serde_json::json!({
|
||||
"allOf": graph.keys().map(|path| {
|
||||
serde_json::json!({"$ref": format!("{RESOURCE}#{}", uri_fragment(path))})
|
||||
}).collect::<Vec<_>>()
|
||||
});
|
||||
jsonschema::options()
|
||||
.with_draft(jsonschema::Draft::Draft202012)
|
||||
.with_resource(RESOURCE, resource)
|
||||
.with_retriever(NoRetrieval)
|
||||
.should_validate_formats(true)
|
||||
.should_ignore_unknown_formats(false)
|
||||
.build(&all_schemas)
|
||||
.map_err(|error| format!("invalid Tool JSON Schema: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct NoRetrieval;
|
||||
|
||||
impl jsonschema::Retrieve for NoRetrieval {
|
||||
fn retrieve(
|
||||
&self,
|
||||
_uri: &jsonschema::Uri<String>,
|
||||
) -> Result<Value, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Err("external schema retrieval is forbidden".into())
|
||||
}
|
||||
}
|
||||
|
||||
fn bound_json(value: &Value) -> Result<(), String> {
|
||||
let mut stack = vec![(value, 0)];
|
||||
let mut count = 0;
|
||||
while let Some((value, depth)) = stack.pop() {
|
||||
count += 1;
|
||||
if count > MAX_NODES || depth > MAX_DEPTH {
|
||||
return Err("Tool JSON Schema exceeds static complexity limits".into());
|
||||
}
|
||||
match value {
|
||||
Value::Array(values) => stack.extend(values.iter().map(|v| (v, depth + 1))),
|
||||
Value::Object(values) => stack.extend(values.values().map(|v| (v, depth + 1))),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if serde_json::to_vec(value).map_err(|e| e.to_string())?.len() > MAX_SCHEMA_BYTES {
|
||||
return Err("Tool JSON Schema exceeds static byte limit".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pointer_child(parent: &str, key: &str) -> String {
|
||||
format!("{parent}/{}", key.replace('~', "~0").replace('/', "~1"))
|
||||
}
|
||||
|
||||
fn uri_fragment(pointer: &str) -> String {
|
||||
let mut encoded = String::new();
|
||||
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
||||
for byte in pointer.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~' | b'/') {
|
||||
encoded.push(char::from(byte));
|
||||
} else {
|
||||
encoded.push('%');
|
||||
encoded.push(char::from(HEX[usize::from(byte >> 4)]));
|
||||
encoded.push(char::from(HEX[usize::from(byte & 15)]));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn inspect_tool_schema(
|
||||
schema: &Value,
|
||||
path: &str,
|
||||
graph: &mut BTreeMap<String, Vec<String>>,
|
||||
) -> Result<(), String> {
|
||||
graph.insert(path.into(), Vec::new());
|
||||
if schema.is_boolean() {
|
||||
return Ok(());
|
||||
}
|
||||
let object = schema
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("schema at #{path} must be an object or boolean"))?;
|
||||
let mut children = Vec::new();
|
||||
let mut references = Vec::new();
|
||||
for (key, value) in object {
|
||||
let location = pointer_child(path, key);
|
||||
match key.as_str() {
|
||||
"$schema" => {
|
||||
if !path.is_empty() || value.as_str() != Some(TOOL_DIALECT) {
|
||||
return Err(format!("unsupported Tool schema dialect at #{location}"));
|
||||
}
|
||||
}
|
||||
"$ref" => {
|
||||
let reference = value
|
||||
.as_str()
|
||||
.ok_or_else(|| format!("$ref at #{location} must be a string"))?;
|
||||
let target = reference.strip_prefix('#').ok_or_else(|| {
|
||||
format!("external Tool schema reference at #{location} is forbidden")
|
||||
})?;
|
||||
if (!target.is_empty() && !target.starts_with('/')) || target.contains('%') {
|
||||
return Err(format!(
|
||||
"only local JSON Pointer $ref is supported at #{location}"
|
||||
));
|
||||
}
|
||||
references.push(target.to_owned());
|
||||
}
|
||||
"$defs" | "properties" | "patternProperties" | "dependentSchemas" => {
|
||||
let entries = value
|
||||
.as_object()
|
||||
.ok_or_else(|| format!("schema map at #{location} must be an object"))?;
|
||||
for (name, child) in entries {
|
||||
children.push((pointer_child(&location, name), child));
|
||||
}
|
||||
}
|
||||
"allOf" | "anyOf" | "oneOf" | "prefixItems" => {
|
||||
let entries = value
|
||||
.as_array()
|
||||
.ok_or_else(|| format!("schema list at #{location} must be an array"))?;
|
||||
for (index, child) in entries.iter().enumerate() {
|
||||
children.push((pointer_child(&location, &index.to_string()), child));
|
||||
}
|
||||
}
|
||||
"items"
|
||||
| "contains"
|
||||
| "additionalProperties"
|
||||
| "unevaluatedProperties"
|
||||
| "unevaluatedItems"
|
||||
| "propertyNames"
|
||||
| "not"
|
||||
| "if"
|
||||
| "then"
|
||||
| "else" => {
|
||||
children.push((location, value));
|
||||
}
|
||||
// These contain data, not subschemas. A property name or literal value
|
||||
// resembling a keyword must not acquire schema semantics.
|
||||
"type" | "enum" | "const" | "multipleOf" | "maximum" | "exclusiveMaximum"
|
||||
| "minimum" | "exclusiveMinimum" | "maxLength" | "minLength" | "pattern" | "format"
|
||||
| "maxItems" | "minItems" | "uniqueItems" | "maxContains" | "minContains"
|
||||
| "maxProperties" | "minProperties" | "required" | "dependentRequired" | "title"
|
||||
| "description" | "default" | "examples" | "deprecated" | "readOnly" | "writeOnly"
|
||||
| "$comment" => {}
|
||||
_ => return Err(format!("unsupported Tool schema keyword at #{location}")),
|
||||
}
|
||||
}
|
||||
references.extend(children.iter().map(|(path, _)| path.clone()));
|
||||
graph.insert(path.into(), references);
|
||||
for (path, child) in children {
|
||||
inspect_tool_schema(child, &path, graph)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_reference_graph(
|
||||
path: &str,
|
||||
graph: &BTreeMap<String, Vec<String>>,
|
||||
active: &mut BTreeSet<String>,
|
||||
complete: &mut BTreeMap<String, usize>,
|
||||
depth: usize,
|
||||
) -> Result<usize, String> {
|
||||
if active.contains(path) {
|
||||
return Err(format!(
|
||||
"recursive Tool schema reference at #{path} is unsupported"
|
||||
));
|
||||
}
|
||||
if depth > MAX_DEPTH {
|
||||
return Err("Tool schema reference graph exceeds static depth limit".into());
|
||||
}
|
||||
let children = graph
|
||||
.get(path)
|
||||
.ok_or_else(|| format!("Tool schema reference does not target a schema: #{path}"))?;
|
||||
if let Some(height) = complete.get(path) {
|
||||
if depth + height > MAX_DEPTH {
|
||||
return Err("Tool schema reference graph exceeds static depth limit".into());
|
||||
}
|
||||
return Ok(*height);
|
||||
}
|
||||
active.insert(path.into());
|
||||
let mut height = 0;
|
||||
for child in children {
|
||||
height = height.max(1 + check_reference_graph(child, graph, active, complete, depth + 1)?);
|
||||
}
|
||||
active.remove(path);
|
||||
complete.insert(path.into(), height);
|
||||
Ok(height)
|
||||
}
|
||||
|
||||
/// Admit a closed static subset of actual DCDL syntax using decodal's parser/AST.
|
||||
///
|
||||
/// The root must be an object. Nested closed objects, homogeneous array constraints
|
||||
/// (`[...String]`), primitive types (`String`, `Bool`, `Int`, `Float`) and scalar
|
||||
/// singleton literals are supported. Object fields must have unique single-part
|
||||
/// paths. Imports, open objects/maps, Unknown, defaults, references, expressions,
|
||||
/// calls and user functions are rejected, not evaluated. This does not validate a
|
||||
/// config instance or establish that its values contain no secrets.
|
||||
pub fn validate_config_schema(source: &str) -> Result<(), String> {
|
||||
if source.len() > MAX_SCHEMA_BYTES {
|
||||
return Err("DCDL config schema exceeds static byte limit".into());
|
||||
}
|
||||
let tokens = decodal::tokenize_source(source)
|
||||
.map_err(|error| format!("invalid DCDL config schema: {}", error.message))?;
|
||||
if tokens.len() > MAX_NODES {
|
||||
return Err("DCDL config schema exceeds static token limit".into());
|
||||
}
|
||||
// Guard parser recursion before parsing untrusted source. Forbid operators
|
||||
// outside the subset here as well, so long right-associative expressions
|
||||
// cannot consume the parser stack before the AST admission check.
|
||||
let mut depth = 0usize;
|
||||
for (index, token) in tokens.iter().enumerate() {
|
||||
match &token.kind {
|
||||
Token::LBrace | Token::LBracket | Token::LParen => {
|
||||
depth += 1;
|
||||
if depth > MAX_DEPTH {
|
||||
return Err("DCDL config schema exceeds static depth limit".into());
|
||||
}
|
||||
}
|
||||
Token::RBrace | Token::RBracket | Token::RParen => {
|
||||
depth = depth.saturating_sub(1);
|
||||
}
|
||||
Token::Minus
|
||||
if tokens
|
||||
.get(index + 1)
|
||||
.is_some_and(|next| matches!(next.kind, Token::Int(_) | Token::Float(_))) => {}
|
||||
Token::Ident(_)
|
||||
| Token::Int(_)
|
||||
| Token::Float(_)
|
||||
| Token::String(_)
|
||||
| Token::True
|
||||
| Token::False
|
||||
| Token::Semicolon
|
||||
| Token::Comma
|
||||
| Token::Ellipsis
|
||||
| Token::Colon
|
||||
| Token::Equal
|
||||
| Token::Comment
|
||||
| Token::Eof => {}
|
||||
_ => return Err("unsupported expression in static DCDL config schema".into()),
|
||||
}
|
||||
}
|
||||
let parsed = decodal::parse_source(source)
|
||||
.map_err(|error| format!("invalid DCDL config schema: {}", error.message))?;
|
||||
let root = unparenthesized(&parsed.ast, parsed.root);
|
||||
if !matches!(parsed.ast.get(root).expr, Expr::Object { .. }) {
|
||||
return Err("DCDL config schema root must be a closed object".into());
|
||||
}
|
||||
inspect_config_schema(&parsed.ast, root)
|
||||
}
|
||||
|
||||
fn unparenthesized(ast: &Ast, mut id: ExprId) -> ExprId {
|
||||
while let Expr::Parenthesized { expr } = ast.get(id).expr {
|
||||
id = expr;
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
fn inspect_config_schema(ast: &Ast, id: ExprId) -> Result<(), String> {
|
||||
match &ast.get(unparenthesized(ast, id)).expr {
|
||||
Expr::Object { fields, rest } => {
|
||||
if rest.is_some() {
|
||||
return Err(
|
||||
"DCDL config schema objects must be closed (no rest constraint)".into(),
|
||||
);
|
||||
}
|
||||
let mut names = BTreeSet::new();
|
||||
for field in fields {
|
||||
if field.path.len() != 1 || !names.insert(&field.path[0]) {
|
||||
return Err("DCDL config schema requires unique single-part field paths".into());
|
||||
}
|
||||
inspect_config_schema(ast, field.value)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Expr::ArrayConstraint { item } => inspect_config_schema(ast, *item),
|
||||
Expr::Ident(name) if matches!(name.as_str(), "String" | "Bool" | "Int" | "Float") => Ok(()),
|
||||
Expr::Literal(Literal::Float(value)) if !value.is_finite() => {
|
||||
Err("DCDL config schema requires finite scalar literals".into())
|
||||
}
|
||||
Expr::Literal(_) => Ok(()),
|
||||
Expr::Unary {
|
||||
op: decodal::ast::UnaryOp::Neg,
|
||||
expr,
|
||||
} if matches!(ast.get(*expr).expr, Expr::Literal(Literal::Int(_)))
|
||||
|| matches!(ast.get(*expr).expr, Expr::Literal(Literal::Float(v)) if v.is_finite()) =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
_ => Err("unsupported node in closed static DCDL config schema".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn tool_schema_accepts_typed_objects_and_local_definitions() {
|
||||
let schema = json!({
|
||||
"$schema": TOOL_DIALECT,
|
||||
"$defs": {"entry/type": {"type": "string", "minLength": 1}},
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"label": {"$ref": "#/$defs/entry~1type"},
|
||||
"values": {"type": "array", "items": {"type": "integer"}}
|
||||
},
|
||||
"required": ["label"],
|
||||
"additionalProperties": false
|
||||
});
|
||||
validate_tool_schema(&schema).unwrap();
|
||||
for schema in [json!({}), json!(true), json!(false)] {
|
||||
validate_tool_schema(&schema).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_rejects_invalid_keyword_shapes_and_types() {
|
||||
for schema in [
|
||||
json!(null),
|
||||
json!([]),
|
||||
json!({"type": "unknown"}),
|
||||
json!({"required": "label"}),
|
||||
json!({"properties": []}),
|
||||
json!({"properties": {"nested": {"type": 3}}}),
|
||||
json!({"allOf": []}),
|
||||
json!({"items": []}),
|
||||
json!({"minLength": -1}),
|
||||
json!({"pattern": "["}),
|
||||
json!({"required": ["x", "x"]}),
|
||||
json!({"format": "unknown-format"}),
|
||||
] {
|
||||
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_rejects_unknown_dialects_keywords_and_nested_extensions() {
|
||||
for schema in [
|
||||
json!({"$schema": "https://example.invalid/schema"}),
|
||||
json!({"$schema": "http://json-schema.org/draft-07/schema#"}),
|
||||
json!({"typo": true}),
|
||||
json!({"$id": "https://example.invalid/base"}),
|
||||
json!({"$dynamicRef": "#node"}),
|
||||
json!({"$anchor": "node"}),
|
||||
json!({"$defs": {"unused": {"typo": true}}}),
|
||||
json!({"properties": {"x": {"$schema": TOOL_DIALECT}}}),
|
||||
json!({"contentSchema": {"type": "string"}}),
|
||||
] {
|
||||
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_rejects_external_dangling_and_recursive_references() {
|
||||
for reference in [
|
||||
"https://example.invalid/schema",
|
||||
"file:///etc/passwd",
|
||||
"other.json",
|
||||
"//example.invalid/schema",
|
||||
"urn:example:schema",
|
||||
"#missing",
|
||||
"#/$defs/missing",
|
||||
"#",
|
||||
"#/%24defs/x",
|
||||
] {
|
||||
assert!(
|
||||
validate_tool_schema(&json!({"$ref": reference})).is_err(),
|
||||
"{reference}"
|
||||
);
|
||||
}
|
||||
for schema in [
|
||||
json!({"$defs": {"unused": {"$ref": "https://example.invalid/schema"}}}),
|
||||
json!({"default": {"type": "string"}, "$ref": "#/default"}),
|
||||
json!({"$defs": {"a": {"$ref": "#/$defs/b"}, "b": {"$ref": "#/$defs/a"}}}),
|
||||
] {
|
||||
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_rejects_invalid_unreferenced_definitions() {
|
||||
for schema in [
|
||||
json!({"$defs": {"unused": {"pattern": "["}}}),
|
||||
json!({"$defs": {"unused": {"format": "unknown-format"}}}),
|
||||
json!({"$defs": {"unused": {"properties": {"x": {"pattern": "["}}}}}),
|
||||
] {
|
||||
assert!(validate_tool_schema(&schema).is_err(), "accepted {schema}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_schema_treats_annotations_and_property_names_as_data() {
|
||||
validate_tool_schema(&json!({
|
||||
"properties": {
|
||||
"$ref": {"type": "string"},
|
||||
"unknown": {"const": {"typo": 1}},
|
||||
"space # % ~ / 日本語": {"type": "string"}
|
||||
},
|
||||
"default": {"$ref": "https://example.invalid/data", "typo": 3},
|
||||
"examples": [{"$schema": "not a schema"}]
|
||||
}))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_schema_accepts_closed_dcdl_types_and_singletons() {
|
||||
for source in [
|
||||
"{}",
|
||||
"label = String; enabled = Bool; retries = Int; ratio = Float",
|
||||
"{ nested = { label = String }; tags = [...String]; rows = [...{ id = Int }] }",
|
||||
"{ mode = \"safe\"; enabled = true; count = 3; ratio = 0.5; offset = -2; delta = -1.5 }",
|
||||
"# DCDL comment\n { label = (String) }",
|
||||
] {
|
||||
validate_config_schema(source).unwrap_or_else(|error| panic!("{source}: {error}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_schema_rejects_open_untyped_and_dynamic_dcdl() {
|
||||
for source in [
|
||||
"Unknown",
|
||||
"String",
|
||||
"[...String]",
|
||||
"{ x = Unknown }",
|
||||
"{ ...String }",
|
||||
"{ x = String; ...Unknown }",
|
||||
"{ nested = { ...Int } }",
|
||||
"{ x = [...Unknown] }",
|
||||
"import \"remote.dcdl\"",
|
||||
"{ x = import \"/etc/passwd\" }",
|
||||
"let T = String in { x = T }",
|
||||
"{ x = MissingType }",
|
||||
"{ x = env(\"TOKEN\") }",
|
||||
"{ x = String default \"hello\" }",
|
||||
"{ x = (v) => v }",
|
||||
"{ x = 1 + 1 }",
|
||||
"{ x = [] }",
|
||||
"{ x = [String] }",
|
||||
"{ x = _ }",
|
||||
"{ x = String; x = Int }",
|
||||
"{ x.y = String }",
|
||||
"{ x = String",
|
||||
"{\"type\": \"object\"}",
|
||||
"{ x = ---1 }",
|
||||
] {
|
||||
assert!(validate_config_schema(source).is_err(), "accepted {source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schemas_reject_oversize_and_deep_inputs_without_evaluation() {
|
||||
assert!(validate_config_schema(&" ".repeat(MAX_SCHEMA_BYTES + 1)).is_err());
|
||||
assert!(
|
||||
validate_config_schema(&format!("{}String{}", "(".repeat(1000), ")".repeat(1000)))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
validate_tool_schema(&json!({"description": "x".repeat(MAX_SCHEMA_BYTES)})).is_err()
|
||||
);
|
||||
let mut nested = json!({});
|
||||
for _ in 0..MAX_DEPTH + 1 {
|
||||
nested = json!({"not": nested});
|
||||
}
|
||||
assert!(validate_tool_schema(&nested).is_err());
|
||||
// Each suffix is visited and cached before the next, longer chain.
|
||||
let mut definitions = serde_json::Map::new();
|
||||
definitions.insert("n00".into(), json!({"type": "string"}));
|
||||
for index in 1..MAX_DEPTH + 2 {
|
||||
definitions.insert(
|
||||
format!("n{index:02}"),
|
||||
json!({
|
||||
"$ref": format!("#/$defs/n{:02}", index - 1)
|
||||
}),
|
||||
);
|
||||
}
|
||||
let error = validate_tool_schema(&json!({"$defs": definitions})).unwrap_err();
|
||||
assert!(error.contains("depth limit"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension cells require a closed typed tree, not a permissive Tool schema.
|
||||
/// Restricting composition/ref keywords here prevents an untyped branch or a
|
||||
/// boolean schema from admitting undeclared fields. New schema shapes require
|
||||
/// deliberate admission changes, never remote retrieval or guest migration.
|
||||
pub fn validate_data_schema(schema: &Value) -> Result<(), String> {
|
||||
validate_tool_schema(schema)?;
|
||||
fn closed(node: &Value) -> Result<(), String> {
|
||||
let object = node
|
||||
.as_object()
|
||||
.ok_or("data schema must be a typed object")?;
|
||||
let kind = object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("data schema requires one explicit type")?;
|
||||
let allowed: &[&str] = match kind {
|
||||
"object" => &[
|
||||
"type",
|
||||
"description",
|
||||
"properties",
|
||||
"required",
|
||||
"additionalProperties",
|
||||
],
|
||||
"array" => &[
|
||||
"type",
|
||||
"description",
|
||||
"items",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
],
|
||||
"string" => &[
|
||||
"type",
|
||||
"description",
|
||||
"enum",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
],
|
||||
"number" | "integer" => &[
|
||||
"type",
|
||||
"description",
|
||||
"enum",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
"multipleOf",
|
||||
],
|
||||
"boolean" | "null" => &["type", "description", "enum"],
|
||||
_ => return Err("unsupported data schema type".into()),
|
||||
};
|
||||
if object.keys().any(|key| !allowed.contains(&key.as_str())) {
|
||||
return Err("unsupported data schema keyword".into());
|
||||
}
|
||||
match kind {
|
||||
"object" => {
|
||||
if object.get("additionalProperties") != Some(&Value::Bool(false)) {
|
||||
return Err("data objects must be closed".into());
|
||||
}
|
||||
let properties = object
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or("data object requires properties")?;
|
||||
for value in properties.values() {
|
||||
closed(value)?;
|
||||
}
|
||||
if let Some(required) = object.get("required").and_then(Value::as_array) {
|
||||
if required
|
||||
.iter()
|
||||
.any(|name| name.as_str().is_none_or(|n| !properties.contains_key(n)))
|
||||
{
|
||||
return Err("required data field is undeclared".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
"array" => closed(
|
||||
object
|
||||
.get("items")
|
||||
.ok_or("data arrays require typed items")?,
|
||||
)?,
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
closed(schema)
|
||||
}
|
||||
|
||||
/// Payload validation uses the same offline closed contract as package admission.
|
||||
pub fn validate_data_value(schema: &Value, value: &Value) -> Result<(), String> {
|
||||
validate_data_schema(schema)?;
|
||||
if serde_json::to_vec(value)
|
||||
.map_err(|_| "invalid data value")?
|
||||
.len()
|
||||
> 64 * 1024
|
||||
{
|
||||
return Err("data value exceeds 64 KiB".into());
|
||||
}
|
||||
let validator = jsonschema::options()
|
||||
.with_draft(jsonschema::Draft::Draft202012)
|
||||
.with_retriever(NoRetrieval)
|
||||
.build(schema)
|
||||
.map_err(|_| "invalid data schema")?;
|
||||
if !validator.is_valid(value) {
|
||||
return Err("data value does not match declared schema".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod data_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn closed_data_schema_rejects_open_or_untyped_branches() {
|
||||
for schema in [
|
||||
json!(true),
|
||||
json!({}),
|
||||
json!({"type":"object"}),
|
||||
json!({"type":"object","properties":{"nested":{}},"additionalProperties":false}),
|
||||
json!({"type":"array"}),
|
||||
json!({"type":"array","items":true}),
|
||||
json!({"type":"object","properties":{},"additionalProperties":false,"anyOf":[{}]}),
|
||||
json!({"type":"object","properties":{},"additionalProperties":false,"required":["undeclared"]}),
|
||||
] {
|
||||
assert!(validate_data_schema(&schema).is_err(), "{schema}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn closed_data_value_enforces_nested_types_and_size() {
|
||||
let schema = json!({"type":"object","additionalProperties":false,"required":["notes"],"properties":{"notes":{"type":"array","items":{"type":"object","properties":{"text":{"type":"string","maxLength":8}},"required":["text"],"additionalProperties":false}}}});
|
||||
validate_data_schema(&schema).unwrap();
|
||||
validate_data_value(&schema, &json!({"notes":[{"text":"ok"}]})).unwrap();
|
||||
for value in [
|
||||
json!({"notes":[{"text":"ok","extra":true}]}),
|
||||
json!({"notes":[{"text":42}]}),
|
||||
json!({"notes":[{"text":"too long text"}]}),
|
||||
json!({"extra":true}),
|
||||
] {
|
||||
assert!(validate_data_value(&schema, &value).is_err());
|
||||
}
|
||||
assert!(validate_data_value(&json!({"type":"string"}), &json!("x".repeat(65536))).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
//! Narrowing requests for Workspace-managed Plugin installations.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Profile input only, not Workspace enablement or a validated execution plan.
|
||||
///
|
||||
/// The Workspace owner must resolve each installation against its enabled exact
|
||||
/// artifact and reject selections outside its Tool/HTTPS/logging ceiling. Neither
|
||||
/// these names nor config values grant authority, select package paths, or cause
|
||||
/// discovery, dynamic registration, or guest execution.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginProfileConfig {
|
||||
#[serde(default)]
|
||||
pub selections: Vec<PluginProfileSelection>,
|
||||
}
|
||||
|
||||
impl PluginProfileConfig {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.selections.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// One installation's explicit subset; omitted surfaces and capabilities are off.
|
||||
///
|
||||
/// `tools` and `https_slots` are package-declared names, not public Tool names,
|
||||
/// URLs, host paths, or connection bindings. `config` contains non-secret override
|
||||
/// candidates only: the Workspace schema validator must reject them unless the
|
||||
/// package's closed typed schema permits those overrides. Deserialization alone
|
||||
/// cannot certify that arbitrary package-specific values are non-secret or valid.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginProfileSelection {
|
||||
pub installation_id: String,
|
||||
#[serde(default)]
|
||||
pub tools: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub services: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub ticket_events: bool,
|
||||
#[serde(default)]
|
||||
pub logging: bool,
|
||||
#[serde(default)]
|
||||
pub https_slots: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub ticket_read: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ticket_data_read: Vec<PluginTicketDataGrant>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub ticket_data_write: Vec<PluginTicketDataGrant>,
|
||||
#[serde(default)]
|
||||
pub config: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginTicketDataGrant {
|
||||
pub collection: String,
|
||||
pub schema_id: String,
|
||||
pub schema_version: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{
|
||||
ProfileSource, WorkerManifest, WorkerManifestConfig,
|
||||
read_persisted_worker_manifest_snapshot, resolve_profile_artifact_value,
|
||||
write_persisted_worker_manifest_snapshot,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn partial(plugins: Value) -> WorkerManifestConfig {
|
||||
serde_json::from_value(json!({
|
||||
"worker": { "name": "plugin-worker" },
|
||||
"plugins": plugins,
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn resolve_profile(plugins: Value) -> crate::ResolvedProfile {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
resolve_profile_artifact_value(
|
||||
json!({ "plugins": plugins }),
|
||||
ProfileSource::Archive {
|
||||
archive_id: "profile-input".into(),
|
||||
source: "test".into(),
|
||||
},
|
||||
dir.path(),
|
||||
"plugin-worker",
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_plugin_selections_and_grants_default_off() {
|
||||
let omitted = WorkerManifestConfig::from_toml("[worker]\nname = 'plain'\n").unwrap();
|
||||
assert!(
|
||||
serde_json::to_value(&omitted)
|
||||
.unwrap()
|
||||
.get("plugins")
|
||||
.is_none()
|
||||
);
|
||||
let manifest = WorkerManifest::try_from(omitted).unwrap();
|
||||
assert!(manifest.plugins.selections.is_empty());
|
||||
assert!(!manifest.requires_persisted_execution_snapshot());
|
||||
assert!(
|
||||
serde_json::to_value(manifest)
|
||||
.unwrap()
|
||||
.get("plugins")
|
||||
.is_none()
|
||||
);
|
||||
|
||||
let manifest = WorkerManifest::try_from(partial(json!({
|
||||
"selections": [{ "installation_id": "installation-a" }]
|
||||
})))
|
||||
.unwrap();
|
||||
let selection = &manifest.plugins.selections[0];
|
||||
assert!(selection.tools.is_empty());
|
||||
assert!(!selection.logging);
|
||||
assert!(selection.https_slots.is_empty());
|
||||
assert!(selection.config.is_empty());
|
||||
assert!(manifest.requires_persisted_execution_snapshot());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_partial_merge_replaces_instead_of_unioning_and_can_clear() {
|
||||
let lower = partial(json!({ "selections": [{
|
||||
"installation_id": "installation-a", "tools": ["read", "write"],
|
||||
"logging": true, "https_slots": ["remote"], "config": {"limit": 10}
|
||||
}] }));
|
||||
let omitted = lower.clone().merge(WorkerManifestConfig::default());
|
||||
assert_eq!(omitted.plugins, lower.plugins);
|
||||
|
||||
let upper = partial(json!({ "selections": [{
|
||||
"installation_id": "installation-a", "tools": ["read"]
|
||||
}] }));
|
||||
let merged = lower.clone().merge(upper.clone());
|
||||
assert_eq!(merged.plugins, upper.plugins);
|
||||
let round_trip: WorkerManifestConfig =
|
||||
serde_json::from_value(serde_json::to_value(&merged).unwrap()).unwrap();
|
||||
assert_eq!(round_trip.plugins, merged.plugins);
|
||||
|
||||
for empty in [json!({}), json!({ "selections": [] })] {
|
||||
let cleared = lower.clone().merge(partial(empty));
|
||||
assert_eq!(cleared.plugins, Some(PluginProfileConfig::default()));
|
||||
let manifest = WorkerManifest::try_from(cleared).unwrap();
|
||||
assert!(manifest.plugins.selections.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_profile_toml_and_resolved_snapshots_preserve_typed_overrides() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let source = r#"
|
||||
[[plugins.selections]]
|
||||
installation_id = "installation-a"
|
||||
tools = ["lookup"]
|
||||
logging = true
|
||||
https_slots = ["api"]
|
||||
[plugins.selections.config]
|
||||
limit = 5
|
||||
labels = ["one", "two"]
|
||||
[plugins.selections.config.nested]
|
||||
strict = true
|
||||
"#;
|
||||
let parsed = WorkerManifestConfig::from_toml(source).unwrap();
|
||||
let authored: toml::Value = toml::from_str(source).unwrap();
|
||||
let resolved = resolve_profile_artifact_value(
|
||||
serde_json::to_value(authored).unwrap(),
|
||||
ProfileSource::Archive {
|
||||
archive_id: "typed-profile".into(),
|
||||
source: "test".into(),
|
||||
},
|
||||
dir.path(),
|
||||
"plugin-worker",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(Some(&resolved.manifest.plugins), parsed.plugins.as_ref());
|
||||
assert_eq!(
|
||||
resolved.manifest.plugins.selections[0].config["nested"],
|
||||
json!({"strict": true})
|
||||
);
|
||||
let snapshot = write_persisted_worker_manifest_snapshot(&resolved.manifest).unwrap();
|
||||
let restored = read_persisted_worker_manifest_snapshot(snapshot.clone()).unwrap();
|
||||
assert_eq!(restored.plugins, resolved.manifest.plugins);
|
||||
assert_eq!(
|
||||
write_persisted_worker_manifest_snapshot(&restored).unwrap(),
|
||||
snapshot
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(restored).unwrap(),
|
||||
resolved.manifest_snapshot
|
||||
);
|
||||
let toml_round_trip = toml::to_string(&resolved.manifest).unwrap();
|
||||
assert_eq!(
|
||||
WorkerManifest::from_toml(&toml_round_trip).unwrap().plugins,
|
||||
resolved.manifest.plugins
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_fields_change_serialized_profile_fingerprint_input() {
|
||||
let baseline = json!({ "selections": [{
|
||||
"installation_id": "installation-a", "tools": ["read"],
|
||||
"logging": false, "https_slots": [], "config": {"limit": 5}
|
||||
}] });
|
||||
let fingerprint = |plugins| {
|
||||
let resolved = resolve_profile(plugins);
|
||||
Sha256::digest(serde_json::to_vec(&resolved.manifest_snapshot).unwrap())
|
||||
};
|
||||
let original = fingerprint(baseline.clone());
|
||||
assert_eq!(fingerprint(baseline.clone()), original);
|
||||
for (field, value) in [
|
||||
("installation_id", json!("installation-b")),
|
||||
("tools", json!(["write"])),
|
||||
("logging", json!(true)),
|
||||
("https_slots", json!(["api"])),
|
||||
("config", json!({"limit": 6})),
|
||||
] {
|
||||
let mut changed = baseline.clone();
|
||||
changed["selections"][0][field] = value;
|
||||
assert_ne!(fingerprint(changed), original, "{field} was lost");
|
||||
}
|
||||
assert_eq!(
|
||||
fingerprint(json!({})),
|
||||
fingerprint(json!({"selections": []}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_config_rejects_legacy_package_and_unknown_authority_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut invalid = vec![
|
||||
json!({ "enabled": [{ "id": "project:legacy" }] }),
|
||||
json!({ "packages": ["/tmp/legacy.yoi-plugin"] }),
|
||||
json!({ "resolved": [{ "package_path": "/tmp/legacy.yoi-plugin" }] }),
|
||||
json!({ "enable": true }),
|
||||
];
|
||||
for (field, value) in [
|
||||
("package_path", json!("/tmp/legacy.yoi-plugin")),
|
||||
("path", json!("./plugin")),
|
||||
("url", json!("https://example.invalid/plugin")),
|
||||
("artifact_digest", json!("sha256:uninstalled")),
|
||||
("secret", json!("not-a-secret-reference")),
|
||||
("data", json!(true)),
|
||||
("events", json!(["changed"])),
|
||||
("websocket", json!(true)),
|
||||
("workdir", json!(true)),
|
||||
("unknown", json!(true)),
|
||||
] {
|
||||
let mut selection = json!({ "installation_id": "installation-a" });
|
||||
selection[field] = value;
|
||||
invalid.push(json!({ "selections": [selection] }));
|
||||
}
|
||||
invalid.extend([
|
||||
json!({ "selections": [{ "tools": ["read"] }] }),
|
||||
json!({ "selections": [{ "installation_id": "a", "config": "raw" }] }),
|
||||
]);
|
||||
for plugins in invalid {
|
||||
let partial_input = json!({ "plugins": plugins.clone() });
|
||||
assert!(
|
||||
serde_json::from_value::<WorkerManifestConfig>(partial_input.clone()).is_err(),
|
||||
"{plugins}"
|
||||
);
|
||||
let toml = toml::to_string(&partial_input).unwrap();
|
||||
assert!(WorkerManifestConfig::from_toml(&toml).is_err(), "{toml}");
|
||||
let error = resolve_profile_artifact_value(
|
||||
partial_input,
|
||||
ProfileSource::Archive {
|
||||
archive_id: "invalid".into(),
|
||||
source: "test".into(),
|
||||
},
|
||||
dir.path(),
|
||||
"plugin-worker",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("failed to decode Profile"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod service_tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn service_selection_is_explicit_and_old_profiles_remain_off() {
|
||||
let old: PluginProfileSelection =
|
||||
serde_json::from_value(json!({"installation_id":"i", "tools":["read"]})).unwrap();
|
||||
assert!(old.services.is_empty());
|
||||
assert!(!old.ticket_events);
|
||||
let retained = serde_json::to_value(&old).unwrap();
|
||||
assert!(retained.get("services").is_none());
|
||||
assert!(retained.get("ticket_events").is_none());
|
||||
let selected: PluginProfileSelection = serde_json::from_value(
|
||||
json!({"installation_id":"i", "services":["watch"], "ticket_events":true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(selected.services, ["watch"]);
|
||||
assert!(selected.ticket_events);
|
||||
assert_ne!(serde_json::to_value(selected).unwrap(), retained);
|
||||
}
|
||||
}
|
||||
@@ -652,6 +652,7 @@ fn resolve_profile_value(
|
||||
permissions: profile.permissions,
|
||||
feature: profile.feature,
|
||||
mcp: profile.mcp,
|
||||
plugins: profile.plugins,
|
||||
compaction,
|
||||
web: profile.web,
|
||||
skills: profile.skills,
|
||||
@@ -700,6 +701,8 @@ struct ProfileConfig {
|
||||
#[serde(default)]
|
||||
mcp: McpConfig,
|
||||
#[serde(default)]
|
||||
plugins: Option<crate::PluginProfileConfig>,
|
||||
#[serde(default)]
|
||||
compaction: Option<serde_json::Value>,
|
||||
#[serde(default)]
|
||||
web: Option<WebConfig>,
|
||||
@@ -1727,6 +1730,139 @@ mod tests {
|
||||
assert!(!resolved.manifest.feature.worker.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_async_chat_exposes_only_opted_in_chat_and_bounded_features() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
.resolve(
|
||||
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "async-chat"),
|
||||
ProfileResolveOptions::with_worker_name("chat-worker"),
|
||||
)
|
||||
.unwrap();
|
||||
let manifest = resolved.manifest;
|
||||
let feature = &manifest.feature;
|
||||
assert_eq!(manifest.engine.instruction, "role.async_chat");
|
||||
assert!(resolved.raw_artifact["scope"].is_null());
|
||||
assert!(resolved.raw_artifact["delegation_scope"].is_null());
|
||||
assert!(manifest.scope.allow.is_empty());
|
||||
assert!(manifest.delegation_scope.allow.is_empty());
|
||||
assert!(manifest.mcp.stdio_servers.is_empty());
|
||||
assert!(feature.subjektiv.profile.enabled);
|
||||
assert!(feature.subjektiv.profile.chat.enabled);
|
||||
assert!(!feature.subjektiv.profile.consolidation_tools);
|
||||
assert!(feature.subjektiv.profile.extraction.enabled);
|
||||
assert!(feature.subjektiv.workspace_settings.is_none());
|
||||
assert!(!feature.subjektiv.execution_enabled());
|
||||
assert!(!feature.memory.profile.enabled);
|
||||
assert!(!feature.memory.profile.staging_tools);
|
||||
assert!(!feature.task.enabled);
|
||||
assert!(!feature.sub_worker.enabled);
|
||||
assert!(!feature.flow.enabled);
|
||||
assert!(feature.worker.enabled);
|
||||
assert!(feature.worker.direct_spawn);
|
||||
assert!(!feature.workspace_worker_discovery.enabled);
|
||||
assert!(feature.objective.enabled);
|
||||
assert!(feature.ticket.enabled);
|
||||
assert!(!feature.ticket.authoring);
|
||||
assert!(!feature.ticket.thread);
|
||||
assert!(!feature.ticket.intake);
|
||||
assert!(!feature.ticket.workflow);
|
||||
assert!(!feature.merge_request.show);
|
||||
assert!(!feature.merge_request.open);
|
||||
assert!(!feature.merge_request.review);
|
||||
assert!(!feature.merge_request.readiness_check);
|
||||
assert!(!feature.merge_request.complete);
|
||||
for flag in [
|
||||
feature.web,
|
||||
feature.image,
|
||||
feature.drive,
|
||||
feature.manage_workdir,
|
||||
feature.workdir_catalog,
|
||||
feature.workspace_config,
|
||||
feature.orchestration,
|
||||
] {
|
||||
assert!(!flag.enabled);
|
||||
}
|
||||
|
||||
let permissions = manifest.permissions.unwrap();
|
||||
assert_eq!(
|
||||
permissions.default_action,
|
||||
crate::ToolPermissionAction::Deny
|
||||
);
|
||||
// Exact tool names and all-argument rules let the Worker statically hide
|
||||
// denied filesystem tools while its permission hook enforces each call.
|
||||
let allowed = permissions
|
||||
.rules
|
||||
.iter()
|
||||
.map(|rule| {
|
||||
assert_eq!(rule.action, crate::ToolPermissionAction::Allow);
|
||||
assert_eq!(rule.pattern, "*");
|
||||
rule.tool.as_str()
|
||||
})
|
||||
.collect::<BTreeSet<_>>();
|
||||
assert_eq!(allowed.len(), permissions.rules.len());
|
||||
assert_eq!(
|
||||
allowed,
|
||||
BTreeSet::from([
|
||||
"ConversationRead",
|
||||
"ConversationSend",
|
||||
"WorkerList",
|
||||
"WorkerSpawn",
|
||||
"WorkerSendInput",
|
||||
"WorkerNotify",
|
||||
"WorkerCancel",
|
||||
"WorkerStop",
|
||||
"WorkerRestore",
|
||||
"ViewSessionOverview",
|
||||
"SearchSessionEntries",
|
||||
"ReadSessionEntry",
|
||||
"SubjektivSessionList",
|
||||
"SubjektivSessionSearch",
|
||||
"SubjektivSessionRead",
|
||||
"SubjektivMemoryQuery",
|
||||
"SubjektivMemoryRead",
|
||||
"SubjektivMemoryListChanges",
|
||||
"SubjektivMemoryRemember",
|
||||
"SubjektivMemoryProposeChange",
|
||||
"QueryObjective",
|
||||
"ShowObjective",
|
||||
"QueryTicket",
|
||||
"ShowTicket",
|
||||
"WorkspaceTagList",
|
||||
"TicketAssignmentTrackingQuery",
|
||||
"TicketAssignmentTrackingHistory",
|
||||
"TicketAssignmentsQuery",
|
||||
"TicketAssignmentDiagnostics",
|
||||
"TicketImplementationResultQuery",
|
||||
"NotificationDiagnostics",
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_profiles_do_not_implicitly_enable_subject_chat() {
|
||||
for entry in builtin_profile_entrypoints() {
|
||||
if entry.selector == Some("builtin:async-chat") {
|
||||
continue;
|
||||
}
|
||||
let artifact = resolve_builtin_profile_artifact(entry.selector.unwrap())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let feature: FeatureConfigPartial =
|
||||
serde_json::from_value(artifact["feature"].clone()).unwrap();
|
||||
assert!(
|
||||
!crate::FeatureConfig::from(feature)
|
||||
.subjektiv
|
||||
.profile
|
||||
.chat
|
||||
.enabled,
|
||||
"{:?} must remain opted out",
|
||||
entry.selector
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_companion_combines_runtime_and_sub_worker_control_with_discovery() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
@@ -1746,7 +1882,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_orchestrator_keeps_cleanup_tool_providers_enabled() {
|
||||
fn builtin_orchestrator_enables_reviewer_handoff_and_cleanup_without_self_review() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let resolved = ProfileResolver::new()
|
||||
.with_workspace_base(tmp.path())
|
||||
@@ -1757,6 +1893,7 @@ mod tests {
|
||||
.unwrap();
|
||||
let feature = resolved.manifest.feature;
|
||||
|
||||
assert!(feature.sub_worker.enabled);
|
||||
assert!(feature.worker.enabled);
|
||||
assert!(!feature.worker.direct_spawn);
|
||||
assert!(feature.manage_workdir.enabled);
|
||||
|
||||
+184
-83
@@ -617,7 +617,8 @@ impl fmt::Display for McpRequestStage {
|
||||
}
|
||||
}
|
||||
|
||||
/// A running initialized stdio MCP client.
|
||||
/// An owned stdio MCP process, initialized by `connect` or explicitly after
|
||||
/// `spawn_uninitialized` when startup cleanup must retain it before any await.
|
||||
pub struct McpStdioClient {
|
||||
server_name: String,
|
||||
limits: McpStdioLimits,
|
||||
@@ -636,6 +637,9 @@ pub struct McpStdioClient {
|
||||
|
||||
impl McpStdioClient {
|
||||
/// Spawn, initialize, negotiate capabilities, and send notifications/initialized.
|
||||
/// Initialization errors attempt shutdown, but this convenience API cannot
|
||||
/// return an unreaped client on failure/cancellation. Its error is not proof
|
||||
/// of cleanup; use `spawn_uninitialized` with retained ownership when needed.
|
||||
pub async fn connect(
|
||||
spec: McpStdioServerSpec,
|
||||
limits: McpStdioLimits,
|
||||
@@ -667,6 +671,21 @@ impl McpStdioClient {
|
||||
async fn spawn(
|
||||
spec: McpStdioServerSpec,
|
||||
limits: McpStdioLimits,
|
||||
) -> Result<Self, McpClientError> {
|
||||
Self::spawn_uninitialized(spec, limits)
|
||||
}
|
||||
|
||||
/// Spawn without awaiting the handshake, so a cleanup owner can retain the
|
||||
/// client before initialization can fail or be cancelled. Call
|
||||
/// `initialize_with_timeout` before discovery; on error/cancellation, shut
|
||||
/// down this same client rather than spawning a replacement.
|
||||
///
|
||||
/// Once the process spawns, this returns its owner even if an expected pipe
|
||||
/// is missing. Initialization then fails, but cleanup can still reap the
|
||||
/// actual child rather than relying on kill-on-drop for a post-spawn error.
|
||||
pub fn spawn_uninitialized(
|
||||
spec: McpStdioServerSpec,
|
||||
limits: McpStdioLimits,
|
||||
) -> Result<Self, McpClientError> {
|
||||
let redactor = spec.redactor();
|
||||
let mut command = Command::new(&spec.command);
|
||||
@@ -688,29 +707,23 @@ impl McpStdioClient {
|
||||
McpErrorKind::Io(redactor.redact(&err.to_string())),
|
||||
)
|
||||
})?;
|
||||
let stdin = child.stdin.take().ok_or_else(|| {
|
||||
McpClientError::new(
|
||||
&spec.name,
|
||||
McpPhase::Spawn,
|
||||
McpErrorKind::Protocol("child stdin was not piped".into()),
|
||||
)
|
||||
})?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| {
|
||||
McpClientError::new(
|
||||
&spec.name,
|
||||
McpPhase::Spawn,
|
||||
McpErrorKind::Protocol("child stdout was not piped".into()),
|
||||
)
|
||||
})?;
|
||||
let stderr = child.stderr.take().ok_or_else(|| {
|
||||
McpClientError::new(
|
||||
&spec.name,
|
||||
McpPhase::Spawn,
|
||||
McpErrorKind::Protocol("child stderr was not piped".into()),
|
||||
)
|
||||
})?;
|
||||
let stdin = child.stdin.take();
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
let missing_pipe = if stdin.is_none() {
|
||||
Some("stdin")
|
||||
} else if stdout.is_none() {
|
||||
Some("stdout")
|
||||
} else if stderr.is_none() {
|
||||
Some("stderr")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let stdin: SharedStdin = Arc::new(Mutex::new(Some(Box::new(stdin))));
|
||||
let stdin: SharedStdin =
|
||||
Arc::new(Mutex::new(stdin.map(|stdin| {
|
||||
Box::new(stdin) as Box<dyn AsyncWrite + Send + Unpin>
|
||||
})));
|
||||
let diagnostics = Arc::new(Mutex::new(BoundedDiagnostics::new(
|
||||
spec.name.clone(),
|
||||
limits.max_diagnostic_lines,
|
||||
@@ -718,16 +731,32 @@ impl McpStdioClient {
|
||||
)));
|
||||
let list_changes = Arc::new(Mutex::new(BoundedListChanged::new(spec.name.clone())));
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
let reader_task = spawn_stdout_reader(
|
||||
spec.name.clone(),
|
||||
stdout,
|
||||
stdin.clone(),
|
||||
tx,
|
||||
limits.clone(),
|
||||
redactor.clone(),
|
||||
list_changes.clone(),
|
||||
);
|
||||
let stderr_task = spawn_stderr_reader(stderr, diagnostics.clone(), limits.clone());
|
||||
// Do not return an error that discards an already spawned child. Queue
|
||||
// setup failure before reader events so initialization cannot succeed.
|
||||
if let Some(pipe) = missing_pipe {
|
||||
tx.try_send(ReaderEvent::Error(McpClientError::new(
|
||||
&spec.name,
|
||||
McpPhase::Spawn,
|
||||
McpErrorKind::Protocol(format!("child {pipe} was not piped")),
|
||||
)))
|
||||
.expect("new response channel has capacity");
|
||||
}
|
||||
let reader_task = match stdout {
|
||||
Some(stdout) => spawn_stdout_reader(
|
||||
spec.name.clone(),
|
||||
stdout,
|
||||
stdin.clone(),
|
||||
tx,
|
||||
limits.clone(),
|
||||
redactor.clone(),
|
||||
list_changes.clone(),
|
||||
),
|
||||
None => tokio::spawn(async {}),
|
||||
};
|
||||
let stderr_task = match stderr {
|
||||
Some(stderr) => spawn_stderr_reader(stderr, diagnostics.clone(), limits.clone()),
|
||||
None => tokio::spawn(async {}),
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
server_name: spec.name,
|
||||
@@ -746,6 +775,25 @@ impl McpStdioClient {
|
||||
})
|
||||
}
|
||||
|
||||
/// Perform the bounded handshake on an already retained client. Failure or
|
||||
/// cancellation does not drop or shut down the client: its owner must do so.
|
||||
/// Do not retry initialization on a partially initialized connection.
|
||||
pub async fn initialize_with_timeout(&mut self) -> Result<(), McpClientError> {
|
||||
let started = Instant::now();
|
||||
match timeout(self.limits.startup_timeout, self.initialize()).await {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(McpClientError::new(
|
||||
&self.server_name,
|
||||
McpPhase::Initialize,
|
||||
McpErrorKind::Timeout {
|
||||
operation: "startup".to_string(),
|
||||
elapsed: started.elapsed(),
|
||||
},
|
||||
)
|
||||
.with_diagnostics(self.snapshot_diagnostics().await)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn initialize(&mut self) -> Result<(), McpClientError> {
|
||||
let result: InitializeResult = self
|
||||
.request(
|
||||
@@ -1080,6 +1128,8 @@ impl McpStdioClient {
|
||||
}
|
||||
|
||||
/// Close stdin and wait for process exit, falling back to terminate and kill.
|
||||
/// Errors and cancellation retain the child for another attempt on this
|
||||
/// client. Only confirmed reaping releases the process handle.
|
||||
pub async fn shutdown(&mut self) -> Result<ShutdownReport, McpClientError> {
|
||||
self.shutdown_started = true;
|
||||
let deadline = Instant::now() + self.limits.shutdown_timeout;
|
||||
@@ -1091,64 +1141,55 @@ impl McpStdioClient {
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
let mut child = match self.child.take() {
|
||||
Some(child) => child,
|
||||
None => return Ok(ShutdownReport::already_finished()),
|
||||
};
|
||||
// Keep the actual process handle in the client across every await and
|
||||
// error. Taking it before wait loses retry/reaping authority on cancel.
|
||||
if self.child.is_none() {
|
||||
return Ok(ShutdownReport::already_finished());
|
||||
}
|
||||
if !closed {
|
||||
self.reader_task.abort();
|
||||
return self.terminate_then_kill(child).await;
|
||||
return self.terminate_then_kill().await;
|
||||
}
|
||||
|
||||
let child = self.child.as_mut().expect("checked child");
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
self.reader_task.abort();
|
||||
self.stderr_task.abort();
|
||||
return Ok(ShutdownReport {
|
||||
exit_status: Some(status),
|
||||
terminated: false,
|
||||
killed: false,
|
||||
});
|
||||
return Ok(self.finish_shutdown(status, false, false));
|
||||
}
|
||||
|
||||
match timeout_at(deadline, child.wait()).await {
|
||||
Ok(Ok(status)) => {
|
||||
self.reader_task.abort();
|
||||
self.stderr_task.abort();
|
||||
Ok(ShutdownReport {
|
||||
exit_status: Some(status),
|
||||
terminated: false,
|
||||
killed: false,
|
||||
})
|
||||
}
|
||||
Ok(Ok(status)) => Ok(self.finish_shutdown(status, false, false)),
|
||||
Ok(Err(err)) => Err(McpClientError::new(
|
||||
&self.server_name,
|
||||
McpPhase::Shutdown,
|
||||
McpErrorKind::Io(self.redactor.redact(&err.to_string())),
|
||||
)
|
||||
.with_diagnostics(self.snapshot_diagnostics().await)),
|
||||
Err(_) => self.terminate_then_kill(child).await,
|
||||
Err(_) => self.terminate_then_kill().await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn terminate_then_kill(
|
||||
fn finish_shutdown(
|
||||
&mut self,
|
||||
mut child: Child,
|
||||
) -> Result<ShutdownReport, McpClientError> {
|
||||
let mut terminated = false;
|
||||
let mut killed = false;
|
||||
if send_terminate(&mut child).is_ok() {
|
||||
terminated = true;
|
||||
status: ExitStatus,
|
||||
terminated: bool,
|
||||
killed: bool,
|
||||
) -> ShutdownReport {
|
||||
// No await separates confirmed reaping from releasing the handle.
|
||||
drop(self.child.take());
|
||||
self.reader_task.abort();
|
||||
self.stderr_task.abort();
|
||||
ShutdownReport {
|
||||
exit_status: Some(status),
|
||||
terminated,
|
||||
killed,
|
||||
}
|
||||
}
|
||||
|
||||
async fn terminate_then_kill(&mut self) -> Result<ShutdownReport, McpClientError> {
|
||||
let child = self.child.as_mut().expect("shutdown retains child");
|
||||
let terminated = send_terminate(child).is_ok();
|
||||
match timeout(self.limits.kill_timeout, child.wait()).await {
|
||||
Ok(Ok(status)) => {
|
||||
self.reader_task.abort();
|
||||
self.stderr_task.abort();
|
||||
Ok(ShutdownReport {
|
||||
exit_status: Some(status),
|
||||
terminated,
|
||||
killed,
|
||||
})
|
||||
}
|
||||
Ok(Ok(status)) => Ok(self.finish_shutdown(status, terminated, false)),
|
||||
Ok(Err(err)) => Err(McpClientError::new(
|
||||
&self.server_name,
|
||||
McpPhase::Shutdown,
|
||||
@@ -1163,7 +1204,6 @@ impl McpStdioClient {
|
||||
McpErrorKind::Io(self.redactor.redact(&err.to_string())),
|
||||
)
|
||||
})?;
|
||||
killed = true;
|
||||
let status = timeout(self.limits.kill_timeout, child.wait())
|
||||
.await
|
||||
.map_err(|_| {
|
||||
@@ -1183,13 +1223,7 @@ impl McpStdioClient {
|
||||
McpErrorKind::Io(self.redactor.redact(&err.to_string())),
|
||||
)
|
||||
})?;
|
||||
self.reader_task.abort();
|
||||
self.stderr_task.abort();
|
||||
Ok(ShutdownReport {
|
||||
exit_status: Some(status),
|
||||
terminated,
|
||||
killed,
|
||||
})
|
||||
Ok(self.finish_shutdown(status, terminated, true))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1197,10 +1231,10 @@ impl McpStdioClient {
|
||||
|
||||
impl Drop for McpStdioClient {
|
||||
fn drop(&mut self) {
|
||||
if !self.shutdown_started {
|
||||
if let Some(child) = &mut self.child {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
// An attempted shutdown is not proof of exit. Errors and cancellation
|
||||
// leave the child here so Drop must still make a best-effort kill.
|
||||
if let Some(child) = &mut self.child {
|
||||
let _ = child.start_kill();
|
||||
}
|
||||
self.reader_task.abort();
|
||||
self.stderr_task.abort();
|
||||
@@ -1784,3 +1818,70 @@ fn send_terminate(child: &mut Child) -> Result<(), ()> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod deadline_tests;
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod shutdown_retention_tests {
|
||||
//! The real client and OS process are used; only the external server is a
|
||||
//! local shell that acknowledges initialization and ignores TERM/EOF.
|
||||
use super::*;
|
||||
use std::future::{Future, poll_fn};
|
||||
use std::task::Poll;
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_graceful_or_terminate_wait_retries_reaping_the_same_child() {
|
||||
for cancel_in_fallback in [false, true] {
|
||||
let script = r#"trap '' TERM
|
||||
read init || exit 1
|
||||
printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{},"serverInfo":{"name":"retained","version":"1"}}}'
|
||||
while :; do :; done
|
||||
"#;
|
||||
let mut client = McpStdioClient::connect(
|
||||
McpStdioServerSpec::new("retained", "/bin/sh").args(["-c", script]),
|
||||
McpStdioLimits {
|
||||
shutdown_timeout: if cancel_in_fallback {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
Duration::from_secs(30)
|
||||
},
|
||||
kill_timeout: Duration::from_secs(30),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("initialize after installing TERM handler");
|
||||
let pid = client.child.as_ref().unwrap().id();
|
||||
let mut shutdown = Box::pin(client.shutdown());
|
||||
poll_fn(|cx| {
|
||||
assert!(shutdown.as_mut().poll(cx).is_pending());
|
||||
Poll::Ready(())
|
||||
})
|
||||
.await;
|
||||
drop(shutdown);
|
||||
assert_eq!(client.child.as_ref().and_then(Child::id), pid);
|
||||
assert!(client.shutdown_started);
|
||||
|
||||
client.limits.shutdown_timeout = Duration::from_millis(20);
|
||||
client.limits.kill_timeout = Duration::from_millis(50);
|
||||
let report = timeout(Duration::from_secs(2), client.shutdown())
|
||||
.await
|
||||
.expect("retry bounded")
|
||||
.expect("retry reaps original child");
|
||||
assert!(
|
||||
report.exit_status.is_some(),
|
||||
"not an already-finished receipt"
|
||||
);
|
||||
assert!(report.killed, "TERM was ignored");
|
||||
assert!(client.child.is_none());
|
||||
let mut status = 0;
|
||||
assert_eq!(
|
||||
unsafe { libc::waitpid(pid.unwrap() as libc::pid_t, &mut status, libc::WNOHANG) },
|
||||
-1
|
||||
);
|
||||
assert_eq!(
|
||||
std::io::Error::last_os_error().raw_os_error(),
|
||||
Some(libc::ECHILD),
|
||||
"shutdown, not a later observer, reaped the original process"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,12 +210,7 @@ impl MergeRequestThreadEvent {
|
||||
}
|
||||
fn bound_bodies(&mut self) {
|
||||
match self {
|
||||
Self::Review(value) => {
|
||||
truncate_body(&mut value.body);
|
||||
for finding in &mut value.findings {
|
||||
truncate_body(&mut finding.body);
|
||||
}
|
||||
}
|
||||
Self::Review(_) => {}
|
||||
Self::ReviewRevoked(value) => truncate_body(&mut value.reason),
|
||||
Self::ReviewCancelled(value) => truncate_body(&mut value.reason),
|
||||
Self::Comment(value) => truncate_body(&mut value.body),
|
||||
@@ -693,6 +688,17 @@ impl MergeRequestStore {
|
||||
pub fn open_merge_request(
|
||||
&self,
|
||||
i: OpenMergeRequest,
|
||||
) -> Result<MergeRequest, MergeRequestError> {
|
||||
self.open_merge_request_with_evidence(i, |_| Ok(()))
|
||||
}
|
||||
|
||||
/// Retain caller-owned evidence atomically with creation. The callback runs
|
||||
/// after the MR and Ticket relation exist, under this store's transaction;
|
||||
/// it must use that transaction rather than reacquire the store connection.
|
||||
pub fn open_merge_request_with_evidence(
|
||||
&self,
|
||||
i: OpenMergeRequest,
|
||||
retain_evidence: impl FnOnce(&Transaction<'_>) -> Result<(), MergeRequestError>,
|
||||
) -> Result<MergeRequest, MergeRequestError> {
|
||||
bounded_body("summary", &i.summary)?;
|
||||
for (n, v) in [
|
||||
@@ -756,6 +762,7 @@ impl MergeRequestStore {
|
||||
None,
|
||||
)?
|
||||
}
|
||||
retain_evidence(&t)?;
|
||||
t.commit()?;
|
||||
drop(c);
|
||||
self.get_by_id(&i.auth.workspace_id, &merge_request_id)
|
||||
@@ -814,7 +821,7 @@ impl MergeRequestStore {
|
||||
));
|
||||
}
|
||||
let mr = self.get_for_operation(&i.auth.workspace_id, &i.merge_request_id, &i.ticket_id)?;
|
||||
self.assigned(&i.auth, &i.ticket_id, &mr.repository_id)?;
|
||||
self.current_assignment_auth(&i.auth, &i.ticket_id, &mr.repository_id)?;
|
||||
if mr.state == MergeRequestState::Closed
|
||||
|| (mr.state == MergeRequestState::Open && mr.selector_from.is_none())
|
||||
{
|
||||
@@ -927,10 +934,6 @@ impl MergeRequestStore {
|
||||
&self,
|
||||
i: SubmitMergeRequestReview,
|
||||
) -> Result<ReviewEvent, MergeRequestError> {
|
||||
bounded_body("review body", &i.body)?;
|
||||
for finding in &i.findings {
|
||||
bounded_body("review finding", &finding.body)?;
|
||||
}
|
||||
let mut c = self.lock()?;
|
||||
let t = c.transaction()?;
|
||||
let g: Option<(String, String, String, String, String, String)> = t
|
||||
@@ -1023,7 +1026,7 @@ impl MergeRequestStore {
|
||||
) -> Result<ReviewRevokedEvent, MergeRequestError> {
|
||||
bounded_body("revocation reason", &i.reason)?;
|
||||
let mr = self.get_for_operation(&i.auth.workspace_id, &i.merge_request_id, &i.ticket_id)?;
|
||||
self.assigned(&i.auth, &i.ticket_id, &mr.repository_id)?;
|
||||
self.current_assignment_auth(&i.auth, &i.ticket_id, &mr.repository_id)?;
|
||||
let r = mr
|
||||
.thread
|
||||
.iter()
|
||||
@@ -1140,7 +1143,7 @@ impl MergeRequestStore {
|
||||
"operation fingerprint mismatch".into(),
|
||||
));
|
||||
}
|
||||
self.completion_auth(&i.auth, &i.ticket_id, &mr.repository_id)?;
|
||||
self.current_assignment_auth(&i.auth, &i.ticket_id, &mr.repository_id)?;
|
||||
if mr.state != MergeRequestState::Open {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Merge Request is not open".into(),
|
||||
@@ -1174,7 +1177,7 @@ impl MergeRequestStore {
|
||||
));
|
||||
}
|
||||
// Case openness is not an integration phase. Exact source approval,
|
||||
// current Ticket content and active assignment fencing above remain
|
||||
// current Ticket content and current assignment fencing above remain
|
||||
// the independent MR integration guards.
|
||||
Ok(())
|
||||
}
|
||||
@@ -1217,7 +1220,7 @@ impl MergeRequestStore {
|
||||
}
|
||||
let assignment_is_current: bool = transaction.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM ticket_active_worker_assignments
|
||||
SELECT 1 FROM ticket_current_worker_assignments
|
||||
WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3
|
||||
)",
|
||||
params![i.auth.workspace_id, i.ticket_id, i.auth.assignment_id],
|
||||
@@ -1536,7 +1539,7 @@ impl MergeRequestStore {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn completion_auth(
|
||||
fn current_assignment_auth(
|
||||
&self,
|
||||
a: &MergeRequestAuth,
|
||||
t: &str,
|
||||
@@ -1735,7 +1738,23 @@ fn ticket_event(
|
||||
) -> Result<(), MergeRequestError> {
|
||||
let ticket = &mr.ticket_ids[0];
|
||||
let n:i64=t.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![mr.workspace_id,ticket],|r|r.get(0))?;
|
||||
t.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body)VALUES(?1,?2,?3,'comment',?4,?5,NULL,NULL,'Merge Request integrated',?6)",params![mr.workspace_id,ticket,n,format!("worker:{}:{}",e.merged_by.runtime_id,e.merged_by.worker_id),e.created_at.to_rfc3339(),format!("Merge Request `{}` integrated approved source ref `{}`; Ticket remains in progress until guarded Ticket completion.",mr.merge_request_id,e.approved_source_ref)])?;
|
||||
t.execute(
|
||||
"INSERT INTO typed_ticket_events(
|
||||
workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body,event_ref
|
||||
) VALUES(?1,?2,?3,'comment',?4,?5,NULL,NULL,'Merge Request integrated',?6,?7)",
|
||||
params![
|
||||
mr.workspace_id,
|
||||
ticket,
|
||||
n,
|
||||
format!("worker:{}:{}", e.merged_by.runtime_id, e.merged_by.worker_id),
|
||||
e.created_at.to_rfc3339(),
|
||||
format!(
|
||||
"Merge Request `{}` integrated approved source ref `{}`; Ticket remains in progress until guarded Ticket completion.",
|
||||
mr.merge_request_id, e.approved_source_ref
|
||||
),
|
||||
format!("{ticket}:{n}"),
|
||||
],
|
||||
)?;
|
||||
for (k, v) in [
|
||||
("implementation_assignment_id", a),
|
||||
("merge_request_id", &mr.merge_request_id),
|
||||
|
||||
@@ -44,6 +44,7 @@ fn content_digest(title: &str) -> String {
|
||||
[title],
|
||||
)
|
||||
.unwrap();
|
||||
connection.execute_batch("CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,body TEXT); INSERT INTO typed_ticket_events VALUES('W','T',-1,'Requirements');").unwrap();
|
||||
ticket::sqlite_ticket_content_digest(&connection, "W", "T")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
@@ -52,7 +53,8 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("db");
|
||||
let c = Connection::open(&p).unwrap();
|
||||
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,title TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t','Title','Requirements');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
|
||||
c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,title TEXT NOT NULL,body TEXT NOT NULL,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_targets(workspace_id TEXT,ticket_id TEXT,ordinal INTEGER,repository_key TEXT,ref_selector TEXT,access TEXT);CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,event_ref TEXT NOT NULL DEFAULT '',PRIMARY KEY(workspace_id,ticket_id,event_index),UNIQUE(workspace_id,ticket_id,event_ref));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t','Title','Requirements');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap();
|
||||
c.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body,event_ref) VALUES('W','T',-1,'comment','Requirements','T:-1')",[]).unwrap();
|
||||
drop(c);
|
||||
let a = Assignments(Arc::new(Mutex::new(CurrentAssignment {
|
||||
assignment_id: "A".into(),
|
||||
@@ -374,6 +376,49 @@ fn approve_for(
|
||||
fn approve(s: &MergeRequestStore, subject: &str, token: &str) -> ReviewEvent {
|
||||
approve_for(s, "MR", "R", subject, token)
|
||||
}
|
||||
#[test]
|
||||
fn review_submission_preserves_long_body_and_findings_on_reload() {
|
||||
for (body, finding_body) in [
|
||||
("b".repeat(16 * 1024 + 1), "finding".into()),
|
||||
("review".into(), "指摘".repeat(4096)),
|
||||
] {
|
||||
let (_dir, store) = fixture();
|
||||
open(&store);
|
||||
request(&store, "source", "token");
|
||||
let findings = vec![ReviewFinding {
|
||||
severity: FindingSeverity::Major,
|
||||
code: None,
|
||||
path: None,
|
||||
line: None,
|
||||
body: finding_body,
|
||||
}];
|
||||
let submitted = store
|
||||
.submit_review(SubmitMergeRequestReview {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
current_subject_ref: "source".into(),
|
||||
capability_token: "token".into(),
|
||||
decision: ReviewDecision::RequestChanges,
|
||||
body: body.clone(),
|
||||
findings: findings.clone(),
|
||||
now: at(4),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(submitted.body, body);
|
||||
assert_eq!(submitted.findings, findings);
|
||||
let reloaded = store.get_by_id("W", "MR").unwrap();
|
||||
let saved = reloaded
|
||||
.thread
|
||||
.iter()
|
||||
.find_map(|event| match event {
|
||||
MergeRequestThreadEvent::Review(review) => Some(review),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(saved, &submitted);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn review_submission_authorization_rejects_invalid_grants_before_side_effects() {
|
||||
let (_d, store) = fixture();
|
||||
@@ -455,6 +500,18 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m
|
||||
.unwrap();
|
||||
assert_eq!(replayed, merged);
|
||||
let connection = Connection::open(d.path().join("db")).unwrap();
|
||||
let ticket_refs = connection
|
||||
.prepare("SELECT event_ref FROM typed_ticket_events WHERE event_index >= 0")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ticket_refs,
|
||||
vec!["T:0"],
|
||||
"replay must not append another post"
|
||||
);
|
||||
let (state, active): (String, bool) = connection.query_row(
|
||||
"SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'",
|
||||
[],
|
||||
@@ -475,6 +532,87 @@ fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_m
|
||||
assert!(!json.contains(banned), "{banned} in {json}")
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn integration_post_failure_rolls_back_and_same_operation_recovers_with_legacy_empty_ref() {
|
||||
let (dir, store) = fixture();
|
||||
open(&store);
|
||||
let review = approve(&store, "source", "approved");
|
||||
request(&store, "source", "pending");
|
||||
let completion = CompleteMergeRequest {
|
||||
merge_request_id: "MR".into(),
|
||||
ticket_id: "T".into(),
|
||||
operation_id: "recover-integration".into(),
|
||||
approval_event_id: review.event_id,
|
||||
current_subject_ref: "source".into(),
|
||||
target_ref_before: "before".into(),
|
||||
target_ref_after: "after".into(),
|
||||
strategy: MergeStrategy::FastForward,
|
||||
resolution: ConflictResolution::None,
|
||||
auth: auth(),
|
||||
now: at(5),
|
||||
};
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
// Retain a comment written before integration populated event_ref.
|
||||
connection.execute_batch(
|
||||
"INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body,event_ref)
|
||||
VALUES('W','T',0,'comment','Legacy integration','');
|
||||
CREATE TRIGGER reject_integration_post BEFORE INSERT ON typed_ticket_events
|
||||
WHEN NEW.heading='Merge Request integrated'
|
||||
BEGIN SELECT RAISE(ABORT,'injected integration post failure'); END;",
|
||||
).unwrap();
|
||||
let before = store.get_by_id("W", "MR").unwrap();
|
||||
let error = store.complete(completion.clone()).unwrap_err();
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("injected integration post failure")
|
||||
);
|
||||
let failed = store.get_by_id("W", "MR").unwrap();
|
||||
assert_eq!(failed.state, MergeRequestState::Open);
|
||||
assert_eq!(failed.thread, before.thread);
|
||||
assert!(store.authorize_review_submission("MR", "pending").is_ok());
|
||||
assert_eq!(
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM typed_ticket_event_attributes",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.unwrap(),
|
||||
0
|
||||
);
|
||||
|
||||
connection
|
||||
.execute_batch("DROP TRIGGER reject_integration_post")
|
||||
.unwrap();
|
||||
let merged = store.complete(completion.clone()).unwrap();
|
||||
assert_eq!(store.complete(completion).unwrap(), merged);
|
||||
assert_eq!(
|
||||
store.get_by_id("W", "MR").unwrap().state,
|
||||
MergeRequestState::Merged
|
||||
);
|
||||
assert!(store.authorize_review_submission("MR", "pending").is_err());
|
||||
let posts = connection
|
||||
.prepare("SELECT event_ref,body FROM typed_ticket_events WHERE event_index >= 0 ORDER BY event_index")
|
||||
.unwrap()
|
||||
.query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)))
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
posts.len(),
|
||||
2,
|
||||
"retry and replay must append exactly one post"
|
||||
);
|
||||
assert_eq!(posts[0], (String::new(), "Legacy integration".into()));
|
||||
assert_eq!(posts[1].0, "T:1");
|
||||
assert!(posts[1].1.contains("`MR`"));
|
||||
assert_eq!(connection.query_row(
|
||||
"SELECT value FROM typed_ticket_event_attributes WHERE event_index=1 AND key='operation_id'",
|
||||
[], |row| row.get::<_, String>(0),
|
||||
).unwrap(), "recover-integration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_move_cancels_submission_and_old_approval_is_reusable_when_source_returns() {
|
||||
let (_d, s) = fixture();
|
||||
@@ -953,7 +1091,7 @@ fn ticket_rescope_requires_fresh_review_before_merge_and_remains_recoverable() {
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
connection
|
||||
.execute_batch(
|
||||
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
|
||||
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL,'T:1')",
|
||||
)
|
||||
.unwrap();
|
||||
connection
|
||||
@@ -1080,7 +1218,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() {
|
||||
let connection = Connection::open(dir.path().join("db")).unwrap();
|
||||
connection
|
||||
.execute_batch(
|
||||
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL)",
|
||||
"UPDATE typed_tickets SET title='Rescoped' WHERE workspace_id='W' AND ticket_id='T'; INSERT INTO typed_ticket_events VALUES('W','T',1,'item_edit','user','t2',NULL,NULL,NULL,NULL,'T:1')",
|
||||
)
|
||||
.unwrap();
|
||||
connection
|
||||
@@ -1305,6 +1443,24 @@ fn partial_and_full_integration_retain_ticket_state_and_active_assignment() {
|
||||
assert_eq!(second_request.merged_result().unwrap(), &second_merge);
|
||||
assert_eq!(first_request.integration_approval().unwrap(), &first);
|
||||
assert_eq!(second_request.integration_approval().unwrap(), &second);
|
||||
let posts = connection
|
||||
.prepare(
|
||||
"SELECT event.event_ref, attribute.value
|
||||
FROM typed_ticket_events event
|
||||
JOIN typed_ticket_event_attributes attribute USING(workspace_id,ticket_id,event_index)
|
||||
WHERE attribute.key='merge_request_id' ORDER BY event.event_index",
|
||||
)
|
||||
.unwrap()
|
||||
.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.unwrap()
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
posts,
|
||||
vec![("T:0".into(), "MR".into()), ("T:1".into(), "MR-2".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1322,7 +1478,7 @@ fn historical_ticket_completion_payload_remains_readable_and_unchanged_on_migrat
|
||||
}"#;
|
||||
connection.execute_batch(
|
||||
"UPDATE typed_tickets SET workflow_state='done' WHERE workspace_id='W' AND ticket_id='T';
|
||||
INSERT INTO typed_ticket_events VALUES('W','T',0,'state_changed','worker:runtime:coder','2026-07-26T12:00:07Z','inprogress','done','Ticket requirements completed','historical body');"
|
||||
INSERT INTO typed_ticket_events VALUES('W','T',0,'state_changed','worker:runtime:coder','2026-07-26T12:00:07Z','inprogress','done','Ticket requirements completed','historical body','T:0');"
|
||||
).unwrap();
|
||||
connection
|
||||
.execute(
|
||||
|
||||
@@ -15,3 +15,6 @@ wit-bindgen = "0.51.0"
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
toml.workspace = true
|
||||
wasmparser = "0.244.0"
|
||||
wit-component = "0.244.0"
|
||||
wit-parser = "0.244.0"
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
# plugin-pdk
|
||||
|
||||
Guest-side JSON/WIT helpers for offline Component Model packages. Without depending on host/runtime crates, it converts Tool and Service inputs/outputs and bounds Tool output size. Using the PDK or validating a package does not authorize installation or execution in a Worker.
|
||||
Guest-side Rust helpers for the versioned Component Model Tool and Service contracts. [`ToolContext`] decodes only non-secret invocation data; [`ToolOutput`] rejects oversized data and separates structured output from presentation text. [`export_component_tool!`] adapts generated WIT bindings and preserves the result's error arm.
|
||||
|
||||
## Main entry points
|
||||
|
||||
- [`run_json_tool`] and [`export_component_tool!`] — typed Tool input/output boundary
|
||||
- [`ToolOutput`] and [`ToolError`] — bounded output and errors
|
||||
- [`ServiceOutput`] and [`export_plugin_instance!`] — instance output contracts
|
||||
Host authorization, schema validation, effect receipts and execution belong to the Host, not this crate. [`export_component_service!`] adapts authored lifecycle/event handlers to the separate exact Service world without a guest task loop. The PDK does not import WASI, implement a Host, or provide Workdir/WebSocket stubs. Old v1 packages require an explicit v2 rebuild rather than a compatibility adapter.
|
||||
|
||||
+239
-627
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
||||
//! Minimal Component Model Tool plugin authoring sketch using `yoi-plugin-pdk`.
|
||||
//!
|
||||
//! Build this as a `wasm32-unknown-unknown` cdylib with Component Model tooling
|
||||
//! and package the adapted component as `plugin.component.wasm`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use yoi_plugin_pdk::wit_bindgen;
|
||||
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "tool",
|
||||
path: "../../../resources/plugin/wit",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EchoInput {
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct EchoOutput<'a> {
|
||||
tool: &'a str,
|
||||
text: String,
|
||||
}
|
||||
|
||||
fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result<ToolOutput, ToolError> {
|
||||
ToolOutput::json(
|
||||
format!("{} ok", ctx.tool_name()),
|
||||
EchoOutput {
|
||||
tool: ctx.tool_name(),
|
||||
text: input.text,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo);
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,50 @@
|
||||
use yoi_plugin_pdk::{ToolError, wit_bindgen};
|
||||
wit_bindgen::generate!({
|
||||
world: "ticket-service-plugin",
|
||||
path: "../../resources/plugin/wit-service",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
fn start(config: String) -> Result<(), ToolError> {
|
||||
if config == "invalid" {
|
||||
Err(ToolError::invalid_input("bad config"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn event(_: exports::yoi::plugin::service::Envelope) -> exports::yoi::plugin::service::EventResult {
|
||||
exports::yoi::plugin::service::EventResult::RetryAfter(20)
|
||||
}
|
||||
fn stop(_: String) -> Result<(), ToolError> {
|
||||
Ok(())
|
||||
}
|
||||
yoi_plugin_pdk::export_component_service!(ServiceProbe, start, event, stop);
|
||||
|
||||
#[test]
|
||||
fn service_macro_preserves_lifecycle_error_arm() {
|
||||
use exports::yoi::plugin::service::Guest;
|
||||
assert_eq!(
|
||||
ServiceProbe::on_start("invalid".into()).unwrap_err().code,
|
||||
"invalid_input"
|
||||
);
|
||||
ServiceProbe::on_stop("shutdown".into()).unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn service_dependencies_preserve_exact_older_pins() {
|
||||
assert_eq!(
|
||||
yoi_plugin_pdk::HOST_WIT,
|
||||
include_str!("../../../resources/plugin/wit-service/deps/yoi-host-v2/yoi-host-v2.wit")
|
||||
);
|
||||
assert_eq!(
|
||||
yoi_plugin_pdk::TICKET_HOST_WIT,
|
||||
include_str!("../../../resources/plugin/wit-service/deps/yoi-host-v2.1/yoi-host-v2.1.wit")
|
||||
);
|
||||
assert_eq!(
|
||||
yoi_plugin_pdk::SERVICE_WORLD,
|
||||
"yoi:plugin/ticket-service-plugin@2.2.0"
|
||||
);
|
||||
assert_eq!(
|
||||
yoi_plugin_pdk::SERVICE_WIT,
|
||||
include_str!("../../../resources/plugin/wit-service/yoi-plugin-v2.2.wit")
|
||||
);
|
||||
}
|
||||
+170
-158
@@ -1,194 +1,206 @@
|
||||
//! Real Rust -> core Wasm -> Component coverage for the shipped starters.
|
||||
//! Only filesystem paths are relocated; WIT and guest source are unchanged.
|
||||
//! Requires the wasm32-unknown-unknown Rust std target and cached Cargo dependencies.
|
||||
//! No guest is instantiated and no HTTPS Host is faked by this suite.
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use toml::Value;
|
||||
use wit_parser::WorldItem;
|
||||
|
||||
const TEMPLATE_CARGO: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/Cargo.toml");
|
||||
const TEMPLATE_LIB: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/src/lib.rs");
|
||||
const TEMPLATE_PLUGIN: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-tool/plugin.toml");
|
||||
const SERVICE_TEMPLATE_CARGO: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/Cargo.toml");
|
||||
const SERVICE_TEMPLATE_LIB: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/src/lib.rs");
|
||||
const SERVICE_TEMPLATE_PLUGIN: &str =
|
||||
include_str!("../../../resources/plugin/templates/rust-component-instance/plugin.toml");
|
||||
// Preserve the removed document's source as a byte-identical fixture to keep the test input unchanged.
|
||||
const SAMPLE_LIB: &str = include_str!("fixtures/component-tool-source.txt");
|
||||
const PDK_CARGO: &str = include_str!("../Cargo.toml");
|
||||
const TEMPLATES: [&str; 2] = ["rust-component-tool", "rust-component-https"];
|
||||
|
||||
#[test]
|
||||
fn rust_component_tool_template_has_expected_files() {
|
||||
let cargo: Value = toml::from_str(TEMPLATE_CARGO).expect("template Cargo.toml parses");
|
||||
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024"));
|
||||
assert!(cargo["workspace"].is_table());
|
||||
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
|
||||
assert_eq!(
|
||||
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
|
||||
Some("../../../../crates/plugin-pdk")
|
||||
);
|
||||
assert!(TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
|
||||
|
||||
let plugin: Value = toml::from_str(TEMPLATE_PLUGIN).expect("template plugin.toml parses");
|
||||
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
|
||||
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component"));
|
||||
assert_eq!(
|
||||
plugin["runtime"]["world"].as_str(),
|
||||
Some("yoi:plugin/tool@1.0.0")
|
||||
);
|
||||
assert!(plugin["tools"].as_array().expect("tools array").len() == 1);
|
||||
|
||||
assert!(TEMPLATE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
|
||||
assert!(TEMPLATE_LIB.contains("wit_bindgen::generate!"));
|
||||
assert!(TEMPLATE_LIB.contains("generate_all"));
|
||||
assert!(TEMPLATE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
|
||||
assert!(TEMPLATE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
|
||||
assert!(TEMPLATE_LIB.contains("ToolOutput::json"));
|
||||
fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_component_service_template_has_event_output_pattern() {
|
||||
let cargo: Value = toml::from_str(SERVICE_TEMPLATE_CARGO).expect("service Cargo.toml parses");
|
||||
assert_eq!(cargo["package"]["edition"].as_str(), Some("2024"));
|
||||
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
|
||||
assert_eq!(
|
||||
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
|
||||
Some("../../../../crates/plugin-pdk")
|
||||
);
|
||||
assert!(SERVICE_TEMPLATE_CARGO.contains("rev = \"<pinned-yoi-commit-sha>\""));
|
||||
|
||||
let plugin: Value =
|
||||
toml::from_str(SERVICE_TEMPLATE_PLUGIN).expect("service plugin.toml parses");
|
||||
assert_eq!(plugin["schema_version"].as_integer(), Some(1));
|
||||
assert_eq!(plugin["runtime"]["kind"].as_str(), Some("wasm-component"));
|
||||
assert_eq!(
|
||||
plugin["runtime"]["world"].as_str(),
|
||||
Some("yoi:plugin/instance@1.0.0")
|
||||
);
|
||||
assert!(
|
||||
plugin["permissions"]
|
||||
.as_array()
|
||||
.expect("permissions array")
|
||||
.iter()
|
||||
.any(|permission| permission["kind"].as_str() == Some("host_api")
|
||||
&& permission["api"].as_str() == Some("websocket"))
|
||||
);
|
||||
assert_eq!(
|
||||
plugin["services"].as_array().expect("services array").len(),
|
||||
1
|
||||
);
|
||||
let ingress = &plugin["ingresses"].as_array().expect("ingresses array")[0];
|
||||
assert!(
|
||||
ingress["event_kinds"]
|
||||
.as_array()
|
||||
.expect("event kinds")
|
||||
.iter()
|
||||
.any(|kind| kind.as_str() == Some("websocket_text"))
|
||||
);
|
||||
assert!(
|
||||
ingress["sources"]
|
||||
.as_array()
|
||||
.expect("sources")
|
||||
.iter()
|
||||
.any(|source| source.as_str() == Some("websocket:wss://example.com/socket"))
|
||||
);
|
||||
let websocket = &plugin["websocket"].as_array().expect("websocket targets")[0];
|
||||
assert_eq!(websocket["scheme"].as_str(), Some("wss"));
|
||||
assert_eq!(websocket["host"].as_str(), Some("example.com"));
|
||||
assert!(
|
||||
websocket["path_prefixes"]
|
||||
.as_array()
|
||||
.expect("websocket path prefixes")
|
||||
.iter()
|
||||
.any(|prefix| prefix.as_str() == Some("/socket"))
|
||||
);
|
||||
|
||||
assert!(SERVICE_TEMPLATE_LIB.contains("world: \"instance\""));
|
||||
assert!(SERVICE_TEMPLATE_LIB.contains("PluginIngressEvent"));
|
||||
assert!(SERVICE_TEMPLATE_LIB.contains("ServiceOutput::websocket_send"));
|
||||
assert!(!SERVICE_TEMPLATE_LIB.contains("recv(timeout"));
|
||||
assert!(!SERVICE_TEMPLATE_PLUGIN.contains("kind = \"wasm\""));
|
||||
fn template_wit_copies_match_the_versioned_contract() {
|
||||
let root = root();
|
||||
for template in TEMPLATES {
|
||||
for file in ["yoi-plugin-v2.wit", "deps/yoi-host/yoi-host-v2.wit"] {
|
||||
assert_eq!(
|
||||
fs::read(root.join("resources/plugin/wit").join(file)).unwrap(),
|
||||
fs::read(
|
||||
root.join("resources/plugin/templates")
|
||||
.join(template)
|
||||
.join("wit")
|
||||
.join(file)
|
||||
)
|
||||
.unwrap(),
|
||||
"{template}/{file} must be regenerated from the canonical WIT"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_tool_fixture_uses_pdk_bindings() {
|
||||
assert!(SAMPLE_LIB.contains("use yoi_plugin_pdk::wit_bindgen"));
|
||||
assert!(SAMPLE_LIB.contains("wit_bindgen::generate!"));
|
||||
assert!(SAMPLE_LIB.contains("generate_all"));
|
||||
assert!(SAMPLE_LIB.contains("runtime_path: \"yoi_plugin_pdk::wit_bindgen::rt\""));
|
||||
assert!(SAMPLE_LIB.contains("yoi_plugin_pdk::export_component_tool!"));
|
||||
assert!(!SAMPLE_LIB.contains("export_name"));
|
||||
fn template_manifest_worlds_use_exact_versions_and_schemas() {
|
||||
for template in TEMPLATES {
|
||||
let path = root().join("resources/plugin/templates").join(template);
|
||||
let cargo: Value =
|
||||
toml::from_str(&fs::read_to_string(path.join("Cargo.toml")).unwrap()).unwrap();
|
||||
assert!(cargo["workspace"].is_table());
|
||||
assert_eq!(cargo["lib"]["crate-type"][0].as_str(), Some("cdylib"));
|
||||
let manifest: Value =
|
||||
toml::from_str(&fs::read_to_string(path.join("plugin.toml")).unwrap()).unwrap();
|
||||
assert_eq!(manifest["schema_version"].as_integer(), Some(2));
|
||||
assert_eq!(manifest["runtime"]["kind"].as_str(), Some("wasm-component"));
|
||||
assert!(
|
||||
manifest["runtime"]["world"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("@2.0.0")
|
||||
);
|
||||
for tool in manifest["tools"].as_array().unwrap() {
|
||||
for schema in ["input_schema", "output_schema"] {
|
||||
assert_eq!(tool[schema]["type"].as_str(), Some("object"));
|
||||
assert_eq!(tool[schema]["additionalProperties"].as_bool(), Some(false));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_template_cargo_checks_for_wasm_target() {
|
||||
cargo_check_template("rust-component-tool");
|
||||
fn pure_starter_builds_real_component_without_imports() {
|
||||
build_component(
|
||||
"rust-component-tool",
|
||||
"yoi_rust_component_tool_template",
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_service_template_cargo_checks_for_wasm_target() {
|
||||
cargo_check_template("rust-component-instance");
|
||||
fn https_starter_builds_real_component_with_only_explicit_host_imports() {
|
||||
build_component(
|
||||
"rust-component-https",
|
||||
"yoi_rust_component_https_template",
|
||||
&[
|
||||
"yoi:host/connections@2.0.0",
|
||||
"yoi:host/errors@2.0.0",
|
||||
"yoi:host/http@2.0.0",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
fn cargo_check_template(template: &str) {
|
||||
let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let template_dir = crate_dir.join(format!("../../resources/plugin/templates/{template}"));
|
||||
let manifest_path = template_dir.join("Cargo.toml");
|
||||
let lock_path = template_dir.join("Cargo.lock");
|
||||
let _ = fs::remove_file(&lock_path);
|
||||
|
||||
let target_dir = tempfile::tempdir().expect("temporary cargo target dir");
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
|
||||
let output = Command::new(cargo)
|
||||
.arg("check")
|
||||
fn build_component(template: &str, library: &str, expected_imports: &[&str]) {
|
||||
let root = root();
|
||||
let sdk = root.join("target/sdk");
|
||||
fs::create_dir_all(&sdk).unwrap();
|
||||
let source = root.join("resources/plugin/templates").join(template);
|
||||
let fixture = tempfile::Builder::new()
|
||||
.prefix("build-")
|
||||
.tempdir_in(&sdk)
|
||||
.unwrap();
|
||||
copy_tree(&source, fixture.path());
|
||||
let cargo_path = fixture.path().join("Cargo.toml");
|
||||
let cargo = fs::read_to_string(&cargo_path).unwrap().replace(
|
||||
"../../../../crates/plugin-pdk",
|
||||
root.join("crates/plugin-pdk").to_str().unwrap(),
|
||||
);
|
||||
fs::write(&cargo_path, cargo).unwrap();
|
||||
let target = sdk.join("guest");
|
||||
let output = Command::new(env!("CARGO"))
|
||||
.args([
|
||||
"build",
|
||||
"--release",
|
||||
"--offline",
|
||||
"--target",
|
||||
"wasm32-unknown-unknown",
|
||||
"--target-dir",
|
||||
])
|
||||
.arg(&target)
|
||||
.arg("--manifest-path")
|
||||
.arg(&manifest_path)
|
||||
.arg("--target")
|
||||
.arg("wasm32-unknown-unknown")
|
||||
.arg("--offline")
|
||||
.arg("--target-dir")
|
||||
.arg(target_dir.path())
|
||||
.arg(&cargo_path)
|
||||
.current_dir(fixture.path())
|
||||
.env("CARGO_TERM_COLOR", "never")
|
||||
.output()
|
||||
.expect("spawn cargo check for embedded template");
|
||||
|
||||
let _ = fs::remove_file(&lock_path);
|
||||
.expect("run guest Cargo build");
|
||||
let logs = sdk.join("logs");
|
||||
fs::create_dir_all(&logs).unwrap();
|
||||
fs::write(logs.join(format!("{template}.stdout.log")), &output.stdout).unwrap();
|
||||
fs::write(logs.join(format!("{template}.stderr.log")), &output.stderr).unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"template cargo check failed\nstatus: {}\nstdout:\n{}\nstderr:\n{}",
|
||||
output.status,
|
||||
"{template}: cargo build failed\n{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let core =
|
||||
fs::read(target.join(format!("wasm32-unknown-unknown/release/{library}.wasm"))).unwrap();
|
||||
let component = wit_component::ComponentEncoder::default()
|
||||
.module(&core)
|
||||
.unwrap()
|
||||
.validate(true)
|
||||
.encode()
|
||||
.unwrap();
|
||||
wasmparser::Validator::new_with_features(wasmparser::WasmFeatures::all())
|
||||
.validate_all(&component)
|
||||
.unwrap();
|
||||
let wit_parser::decoding::DecodedWasm::Component(resolve, world) =
|
||||
wit_component::decode(&component).unwrap()
|
||||
else {
|
||||
panic!("expected Component, not WIT package")
|
||||
};
|
||||
let world = &resolve.worlds[world];
|
||||
let mut imports: Vec<_> = world
|
||||
.imports
|
||||
.keys()
|
||||
.map(|key| resolve.name_world_key(key))
|
||||
.collect();
|
||||
imports.sort();
|
||||
assert_eq!(imports, expected_imports);
|
||||
let exports: Vec<_> = world
|
||||
.exports
|
||||
.keys()
|
||||
.map(|key| resolve.name_world_key(key))
|
||||
.collect();
|
||||
assert_eq!(exports, ["yoi:plugin/tool@2.0.0"]);
|
||||
let WorldItem::Interface { id, .. } = world.exports.values().next().unwrap() else {
|
||||
panic!("Tool interface export required")
|
||||
};
|
||||
assert_eq!(
|
||||
resolve.interfaces[*id].functions.keys().collect::<Vec<_>>(),
|
||||
[&"invoke".to_string()]
|
||||
);
|
||||
|
||||
// Retain actual package inputs for the shared validator/pack owner, not a mock .wasm.
|
||||
let package = sdk.join("fixtures").join(template);
|
||||
fs::create_dir_all(&package).unwrap();
|
||||
fs::copy(source.join("plugin.toml"), package.join("plugin.toml")).unwrap();
|
||||
fs::write(package.join("plugin.component.wasm"), component).unwrap();
|
||||
}
|
||||
|
||||
fn copy_tree(source: &Path, destination: &Path) {
|
||||
fs::create_dir_all(destination).unwrap();
|
||||
for entry in fs::read_dir(source).unwrap() {
|
||||
let entry = entry.unwrap();
|
||||
let kind = entry.file_type().unwrap();
|
||||
assert!(!kind.is_symlink(), "template may not contain symlinks");
|
||||
let name = entry.file_name();
|
||||
if name == "target" || name == "Cargo.lock" {
|
||||
continue;
|
||||
}
|
||||
let copied = destination.join(name);
|
||||
if kind.is_dir() {
|
||||
copy_tree(&entry.path(), &copied);
|
||||
} else {
|
||||
fs::copy(entry.path(), copied).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdk_runtime_dependencies_are_guest_side_only() {
|
||||
let cargo: Value = toml::from_str(PDK_CARGO).expect("PDK Cargo.toml parses");
|
||||
let dependencies = cargo["dependencies"]
|
||||
.as_table()
|
||||
.expect("dependencies table");
|
||||
let forbidden = [
|
||||
"worker",
|
||||
"yoi-worker",
|
||||
"agen",
|
||||
"tui",
|
||||
"yoi-tui",
|
||||
"client",
|
||||
"yoi-client",
|
||||
"manifest",
|
||||
"yoi-manifest",
|
||||
"ticket",
|
||||
"yoi-ticket",
|
||||
];
|
||||
|
||||
for name in forbidden {
|
||||
let cargo: Value = toml::from_str(include_str!("../Cargo.toml")).unwrap();
|
||||
for dependency in cargo["dependencies"].as_table().unwrap().keys() {
|
||||
assert!(
|
||||
!dependencies.contains_key(name),
|
||||
"PDK must not depend on host/runtime crate `{name}`"
|
||||
["serde", "serde_json", "thiserror", "wit-bindgen"].contains(&dependency.as_str()),
|
||||
"unexpected Host dependency {dependency}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//! Compile-pass: the additive world uses the original Tool export and typed
|
||||
//! imported Ticket resources; no handwritten canonical ABI or ambient authority.
|
||||
use yoi_plugin_pdk::wit_bindgen;
|
||||
wit_bindgen::generate!({
|
||||
world: "ticket-tool-plugin",
|
||||
path: "../../resources/plugin/wit-ticket",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
fn handle(
|
||||
_: yoi_plugin_pdk::ToolContext,
|
||||
input: String,
|
||||
) -> yoi_plugin_pdk::Result<yoi_plugin_pdk::ToolOutput> {
|
||||
yoi_plugin_pdk::ToolOutput::summary(input)
|
||||
}
|
||||
yoi_plugin_pdk::export_ticket_component_tool!(TicketProbe, handle);
|
||||
|
||||
#[test]
|
||||
fn additive_export_uses_original_tool_result_abi() {
|
||||
use exports::yoi::plugin2_0_0::tool::{Guest, Invocation};
|
||||
let result = TicketProbe::invoke(Invocation {
|
||||
invocation_id: "invocation".into(),
|
||||
tool_name: "probe".into(),
|
||||
input_json: "\"ok\"".into(),
|
||||
config_json: "{}".into(),
|
||||
remaining_ms: 1000,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result.summary, "ok");
|
||||
let _access = yoi::host2_1_0::objects::Access::ExtensionWrite;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_wit_dependencies_are_exact_copies_not_augmented_versions() {
|
||||
assert_eq!(
|
||||
include_str!("../../../resources/plugin/wit/yoi-plugin-v2.wit"),
|
||||
include_str!("../../../resources/plugin/wit-ticket/deps/yoi-plugin-v2/yoi-plugin-v2.wit")
|
||||
);
|
||||
assert_eq!(
|
||||
include_str!("../../../resources/plugin/wit/deps/yoi-host/yoi-host-v2.wit"),
|
||||
include_str!("../../../resources/plugin/wit-ticket/deps/yoi-host-v2/yoi-host-v2.wit")
|
||||
);
|
||||
assert_ne!(
|
||||
yoi_plugin_pdk::TICKET_TOOL_WORLD,
|
||||
yoi_plugin_pdk::TOOL_WORLD
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,47 @@
|
||||
//! Compile-pass coverage for generated v2 bindings and the public adapter macro.
|
||||
use yoi_plugin_pdk::wit_bindgen;
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "tool",
|
||||
world: "tool-plugin",
|
||||
path: "../../resources/plugin/wit",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
|
||||
struct Probe;
|
||||
fn handle(
|
||||
ctx: yoi_plugin_pdk::ToolContext,
|
||||
input: serde_json::Value,
|
||||
) -> yoi_plugin_pdk::Result<yoi_plugin_pdk::ToolOutput> {
|
||||
if ctx.tool_name() != "probe" {
|
||||
return Err(yoi_plugin_pdk::ToolError::unsupported_tool(ctx.tool_name()));
|
||||
}
|
||||
yoi_plugin_pdk::ToolOutput::json("probe ok", input)
|
||||
}
|
||||
|
||||
impl Guest for Probe {
|
||||
fn call(tool_name: String, input_json: String) -> String {
|
||||
yoi_plugin_pdk::run_json_tool(&tool_name, &input_json, |_ctx, input: serde_json::Value| {
|
||||
yoi_plugin_pdk::ToolOutput::json("probe ok", input)
|
||||
})
|
||||
yoi_plugin_pdk::export_component_tool!(Probe, handle);
|
||||
|
||||
fn invocation(input_json: &str) -> exports::yoi::plugin::tool::Invocation {
|
||||
exports::yoi::plugin::tool::Invocation {
|
||||
invocation_id: "call-1".into(),
|
||||
tool_name: "probe".into(),
|
||||
input_json: input_json.into(),
|
||||
config_json: "{}".into(),
|
||||
remaining_ms: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wit_bindgen_generates_current_tool_world() {
|
||||
let output = <Probe as Guest>::call("probe".to_string(), r#"{"ok":true}"#.to_string());
|
||||
let value: serde_json::Value = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(value["summary"], "probe ok");
|
||||
fn generated_adapter_returns_record_and_keeps_business_error_in_error_arm() {
|
||||
use exports::yoi::plugin::tool::Guest;
|
||||
let output = Probe::invoke(invocation(r#"{"ok":true}"#)).unwrap();
|
||||
assert_eq!(output.summary, "probe ok");
|
||||
assert_eq!(output.content, None);
|
||||
assert_eq!(output.structured_json.as_deref(), Some(r#"{"ok":true}"#));
|
||||
assert_eq!(
|
||||
Probe::invoke(invocation("broken")).unwrap_err().code,
|
||||
"invalid_input"
|
||||
);
|
||||
let mut input = invocation("{}");
|
||||
input.tool_name = "unknown".into();
|
||||
assert_eq!(Probe::invoke(input).unwrap_err().code, "unsupported_tool");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "plugin-plan"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
manifest.workspace = true
|
||||
schemars.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
@@ -0,0 +1,5 @@
|
||||
# Portable Plugin execution plan
|
||||
|
||||
This crate owns the immutable [`WorkerPluginExecutionPlan`] model, validation and canonical fingerprint shared by Server persistence and Runtime launch snapshots. [`ArtifactDescriptor`] projects only the artifact metadata needed to construct or compare exact pins without depending on Server API types.
|
||||
|
||||
It does not own catalog admission, persistence, configuration resolution, artifact delivery or authorization. A deserialized plan or owner-supplied artifact projection is not evidence of trust or live execution authority; callers must enforce those boundaries with their owners.
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"input":{"config":{"content_digest":"sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd","toolchain_fingerprint":"toolchain"},"execution":{"delegation_id":"delegation","principal_id":"principal","runtime_id":"runtime","worker_id":"worker"},"plugins":[{"artifact_digest":"sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","artifact_size":42,"component_world":"yoi:plugin/https-logged-tool-plugin@2.0.0","config_digest":"sha256:4bf0a2da94b0c881343726db532ddbfeeed37790af7da44e790cc0bba708654e","config_schema":{"digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc","identity":"config","namespace":"example","version":"1"},"expected_last_operation":"operation","installation_id":"installation","package_digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","package_id":"package","package_schema_version":2,"package_version":"1.0.0","projection":{"bindings":[{"binding_id":"binding","policy_digest":"sha256:ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff","slot":"api"}],"config":{"a":[{"a":false,"z":0}],"z":{"a":1,"b":2}},"grants":[{"kind":"logging"},{"kind":"https","slot":"api"}],"surfaces":["tool"],"tools":["echo","other"]},"required_imports":["https","logging"]}],"profile":{"identity":"profile:coder","projection_digest":"sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"},"workspace_id":"workspace"}}
|
||||
@@ -0,0 +1,507 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
use manifest::plugin;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum PlanError {
|
||||
#[error("invalid Plugin execution plan")]
|
||||
Invalid,
|
||||
#[error("Plugin execution plan conflict")]
|
||||
Conflict,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, PlanError>;
|
||||
|
||||
/// Neutral metadata used to validate and reproduce an exact pin. This excludes
|
||||
/// descriptions, Tool schemas, schema source, credentials and artifact locations.
|
||||
/// A projection is not proof of catalog admission or current authorization.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ArtifactIdentityProjection {
|
||||
pub package_id: String,
|
||||
pub version: String,
|
||||
pub package_digest: String,
|
||||
pub artifact_digest: String,
|
||||
pub artifact_size: u32,
|
||||
pub schema_version: u32,
|
||||
pub world: String,
|
||||
pub required_imports: Vec<String>,
|
||||
pub tools: Vec<String>,
|
||||
pub services: Vec<String>,
|
||||
pub connections: Vec<String>,
|
||||
pub requested_grants: Vec<PluginGrant>,
|
||||
pub config_schema: PluginSchemaIdentity,
|
||||
pub revoked: bool,
|
||||
}
|
||||
|
||||
/// Adapts an owner's artifact metadata without coupling the portable model to
|
||||
/// that owner's API. Callers must still perform admission with the owner; an
|
||||
/// implementation of this trait does not establish metadata trust.
|
||||
pub trait ArtifactDescriptor {
|
||||
fn artifact_identity(&self) -> ArtifactIdentityProjection;
|
||||
}
|
||||
|
||||
impl ArtifactDescriptor for ArtifactIdentityProjection {
|
||||
fn artifact_identity(&self) -> ArtifactIdentityProjection {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ConfigProvenance {
|
||||
pub content_digest: String,
|
||||
pub toolchain_fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProfileProvenance {
|
||||
pub identity: String,
|
||||
pub projection_digest: String,
|
||||
}
|
||||
|
||||
/// Supplied by the authenticated Host launch/delegation boundary, never by guest
|
||||
/// or model input. These are identities only, not credentials or provider handles.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HostExecutionIdentity {
|
||||
pub runtime_id: String,
|
||||
pub worker_id: String,
|
||||
pub principal_id: String,
|
||||
pub delegation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct BindingPolicyReference {
|
||||
pub slot: String,
|
||||
pub binding_id: String,
|
||||
pub policy_digest: String,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
|
||||
)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PluginSurface {
|
||||
Tool,
|
||||
Service,
|
||||
}
|
||||
|
||||
/// Exact grant identities; new operations are never implied by old pins.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PluginGrant {
|
||||
Logging,
|
||||
Https {
|
||||
slot: String,
|
||||
},
|
||||
TicketRead,
|
||||
TicketEvents,
|
||||
TicketDataRead {
|
||||
collection: String,
|
||||
schema_id: String,
|
||||
schema_version: String,
|
||||
},
|
||||
TicketDataWrite {
|
||||
collection: String,
|
||||
schema_id: String,
|
||||
schema_version: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginSchemaIdentity {
|
||||
pub identity: String,
|
||||
pub version: String,
|
||||
pub digest: String,
|
||||
pub namespace: String,
|
||||
}
|
||||
|
||||
/// The upstream config/schema authority must validate config values as non-secret
|
||||
/// and intersect grants/surfaces with Workspace, Profile and delegation bounds.
|
||||
/// Binding policies remain with their owner: only identities and digests travel.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginPinProjection {
|
||||
pub config: Value,
|
||||
pub surfaces: Vec<PluginSurface>,
|
||||
pub tools: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub services: Vec<String>,
|
||||
pub grants: Vec<PluginGrant>,
|
||||
pub bindings: Vec<BindingPolicyReference>,
|
||||
}
|
||||
|
||||
/// All fields are private: construct from admitted artifact metadata, not a
|
||||
/// hand-built wire snapshot. Deserialization alone does not establish admission.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerPluginPin {
|
||||
installation_id: String,
|
||||
expected_last_operation: String,
|
||||
package_id: String,
|
||||
package_version: String,
|
||||
package_digest: String,
|
||||
artifact_digest: String,
|
||||
artifact_size: u32,
|
||||
package_schema_version: u32,
|
||||
component_world: String,
|
||||
required_imports: Vec<String>,
|
||||
config_schema: PluginSchemaIdentity,
|
||||
config_digest: String,
|
||||
projection: PluginPinProjection,
|
||||
}
|
||||
|
||||
impl WorkerPluginPin {
|
||||
pub fn from_artifact(
|
||||
installation_id: String,
|
||||
expected_last_operation: String,
|
||||
artifact: &(impl ArtifactDescriptor + ?Sized),
|
||||
mut projection: PluginPinProjection,
|
||||
) -> Result<Self> {
|
||||
let artifact = artifact.artifact_identity();
|
||||
if artifact.revoked {
|
||||
return conflict();
|
||||
}
|
||||
if !valid_identity(&installation_id)
|
||||
|| !valid_identity(&expected_last_operation)
|
||||
|| !valid_digest(&artifact.package_digest)
|
||||
|| !valid_digest(&artifact.artifact_digest)
|
||||
|| artifact.artifact_size == 0
|
||||
|| artifact.schema_version != plugin::SUPPORTED_PLUGIN_API_VERSION
|
||||
|| !matches!(
|
||||
artifact.world.as_str(),
|
||||
plugin::PLUGIN_COMPONENT_TOOL_WORLD
|
||||
| plugin::PLUGIN_COMPONENT_HTTPS_TOOL_WORLD
|
||||
| plugin::PLUGIN_COMPONENT_LOGGED_TOOL_WORLD
|
||||
| plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD
|
||||
| plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD
|
||||
| plugin::PLUGIN_COMPONENT_SERVICE_WORLD
|
||||
)
|
||||
|| !valid_digest(&artifact.config_schema.digest)
|
||||
|| !valid_identity(&artifact.package_id)
|
||||
|| !valid_identity(&artifact.version)
|
||||
|| !valid_identity(&artifact.config_schema.identity)
|
||||
|| !valid_identity(&artifact.config_schema.version)
|
||||
|| !valid_identity(&artifact.config_schema.namespace)
|
||||
|| !projection.config.is_object()
|
||||
{
|
||||
return invalid();
|
||||
}
|
||||
projection.surfaces.sort();
|
||||
projection.surfaces.dedup();
|
||||
projection.tools.sort();
|
||||
projection.tools.dedup();
|
||||
projection.services.sort();
|
||||
projection.services.dedup();
|
||||
projection.grants.sort();
|
||||
projection.grants.dedup();
|
||||
projection.bindings.sort();
|
||||
projection.config = canonical_value(&projection.config);
|
||||
if (!projection.tools.is_empty() && !projection.surfaces.contains(&PluginSurface::Tool))
|
||||
|| projection.tools.iter().any(|name| {
|
||||
!valid_identity(name) || !artifact.tools.iter().any(|tool| *tool == *name)
|
||||
})
|
||||
|| (!projection.services.is_empty()
|
||||
&& !projection.surfaces.contains(&PluginSurface::Service))
|
||||
|| (projection.surfaces.contains(&PluginSurface::Service)
|
||||
&& (projection.services.is_empty()
|
||||
|| !projection.grants.contains(&PluginGrant::TicketEvents)))
|
||||
|| projection.services.iter().any(|name| {
|
||||
!valid_identity(name)
|
||||
|| !artifact.services.contains(name)
|
||||
|| artifact.world != plugin::PLUGIN_COMPONENT_SERVICE_WORLD
|
||||
})
|
||||
|| projection
|
||||
.bindings
|
||||
.windows(2)
|
||||
.any(|b| b[0].slot == b[1].slot)
|
||||
|| projection.bindings.iter().any(|binding| {
|
||||
!valid_identity(&binding.slot)
|
||||
|| !valid_identity(&binding.binding_id)
|
||||
|| !valid_digest(&binding.policy_digest)
|
||||
|| !artifact
|
||||
.connections
|
||||
.iter()
|
||||
.any(|slot| *slot == binding.slot)
|
||||
})
|
||||
|| projection.grants.iter().any(|grant| match grant {
|
||||
PluginGrant::TicketRead
|
||||
| PluginGrant::TicketDataRead { .. }
|
||||
| PluginGrant::TicketDataWrite { .. } => {
|
||||
!matches!(
|
||||
artifact.world.as_str(),
|
||||
plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD
|
||||
| plugin::PLUGIN_COMPONENT_SERVICE_WORLD
|
||||
) || !artifact.requested_grants.contains(grant)
|
||||
|| match grant {
|
||||
PluginGrant::TicketDataRead {
|
||||
collection,
|
||||
schema_id,
|
||||
schema_version,
|
||||
}
|
||||
| PluginGrant::TicketDataWrite {
|
||||
collection,
|
||||
schema_id,
|
||||
schema_version,
|
||||
} => [collection, schema_id, schema_version]
|
||||
.iter()
|
||||
.any(|v| !valid_identity(v) || v.len() > 128),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
PluginGrant::TicketEvents => {
|
||||
artifact.world != plugin::PLUGIN_COMPONENT_SERVICE_WORLD
|
||||
|| !artifact.requested_grants.contains(grant)
|
||||
|| projection.services.is_empty()
|
||||
}
|
||||
PluginGrant::Logging => !artifact.requested_grants.contains(&PluginGrant::Logging),
|
||||
PluginGrant::Https { slot } => {
|
||||
!valid_identity(slot)
|
||||
|| !artifact
|
||||
.requested_grants
|
||||
.contains(&PluginGrant::Https { slot: slot.clone() })
|
||||
|| !projection
|
||||
.bindings
|
||||
.iter()
|
||||
.any(|binding| binding.slot == *slot)
|
||||
}
|
||||
})
|
||||
|| projection.bindings.iter().any(|binding| {
|
||||
!projection.grants.contains(&PluginGrant::Https {
|
||||
slot: binding.slot.clone(),
|
||||
})
|
||||
})
|
||||
{
|
||||
return invalid();
|
||||
}
|
||||
let mut required_imports = artifact.required_imports.clone();
|
||||
required_imports.sort();
|
||||
required_imports.dedup();
|
||||
Ok(Self {
|
||||
installation_id,
|
||||
expected_last_operation,
|
||||
package_id: artifact.package_id.clone(),
|
||||
package_version: artifact.version.clone(),
|
||||
package_digest: artifact.package_digest.clone(),
|
||||
artifact_digest: artifact.artifact_digest.clone(),
|
||||
artifact_size: artifact.artifact_size,
|
||||
package_schema_version: artifact.schema_version,
|
||||
component_world: artifact.world.clone(),
|
||||
required_imports,
|
||||
config_schema: PluginSchemaIdentity {
|
||||
identity: artifact.config_schema.identity.clone(),
|
||||
version: artifact.config_schema.version.clone(),
|
||||
digest: artifact.config_schema.digest.clone(),
|
||||
namespace: artifact.config_schema.namespace.clone(),
|
||||
},
|
||||
config_digest: digest_json(&projection.config)?,
|
||||
projection,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn installation_id(&self) -> &str {
|
||||
&self.installation_id
|
||||
}
|
||||
pub fn expected_last_operation(&self) -> &str {
|
||||
&self.expected_last_operation
|
||||
}
|
||||
pub fn package_id(&self) -> &str {
|
||||
&self.package_id
|
||||
}
|
||||
pub fn package_version(&self) -> &str {
|
||||
&self.package_version
|
||||
}
|
||||
pub fn package_schema_version(&self) -> u32 {
|
||||
self.package_schema_version
|
||||
}
|
||||
pub fn component_world(&self) -> &str {
|
||||
&self.component_world
|
||||
}
|
||||
pub fn required_imports(&self) -> &[String] {
|
||||
&self.required_imports
|
||||
}
|
||||
pub fn package_digest(&self) -> &str {
|
||||
&self.package_digest
|
||||
}
|
||||
pub fn artifact_digest(&self) -> &str {
|
||||
&self.artifact_digest
|
||||
}
|
||||
pub fn artifact_size(&self) -> u32 {
|
||||
self.artifact_size
|
||||
}
|
||||
pub fn config_schema(&self) -> &PluginSchemaIdentity {
|
||||
&self.config_schema
|
||||
}
|
||||
pub fn config_digest(&self) -> &str {
|
||||
&self.config_digest
|
||||
}
|
||||
pub fn projection(&self) -> &PluginPinProjection {
|
||||
&self.projection
|
||||
}
|
||||
|
||||
pub fn matches_artifact(&self, artifact: &(impl ArtifactDescriptor + ?Sized)) -> Result<bool> {
|
||||
Ok(Self::from_artifact(
|
||||
self.installation_id.clone(),
|
||||
self.expected_last_operation.clone(),
|
||||
artifact,
|
||||
self.projection.clone(),
|
||||
)? == *self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerPluginPlanInput {
|
||||
pub workspace_id: String,
|
||||
pub config: ConfigProvenance,
|
||||
pub profile: ProfileProvenance,
|
||||
pub execution: HostExecutionIdentity,
|
||||
pub plugins: Vec<WorkerPluginPin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerPluginExecutionPlan {
|
||||
format_version: u32,
|
||||
input: WorkerPluginPlanInput,
|
||||
}
|
||||
|
||||
impl WorkerPluginExecutionPlan {
|
||||
pub fn canonicalize(mut input: WorkerPluginPlanInput) -> Result<Self> {
|
||||
if !valid_identity(&input.workspace_id)
|
||||
|| !valid_digest(&input.config.content_digest)
|
||||
|| !valid_identity(&input.config.toolchain_fingerprint)
|
||||
|| !valid_identity(&input.profile.identity)
|
||||
|| !valid_digest(&input.profile.projection_digest)
|
||||
|| !valid_identity(&input.execution.runtime_id)
|
||||
|| !valid_identity(&input.execution.worker_id)
|
||||
|| !valid_identity(&input.execution.principal_id)
|
||||
|| input
|
||||
.execution
|
||||
.delegation_id
|
||||
.as_ref()
|
||||
.is_some_and(|id| !valid_identity(id))
|
||||
|| input.plugins.len() > 128
|
||||
{
|
||||
return invalid();
|
||||
}
|
||||
input
|
||||
.plugins
|
||||
.sort_by(|a, b| a.installation_id.cmp(&b.installation_id));
|
||||
// One selection per installation; two aliases of one package instead use
|
||||
// distinct installation identities, as required by the catalog contract.
|
||||
if input
|
||||
.plugins
|
||||
.windows(2)
|
||||
.any(|pins| pins[0].installation_id == pins[1].installation_id)
|
||||
{
|
||||
return invalid();
|
||||
}
|
||||
// Public Tool names use immutable installation identities, never aliases.
|
||||
let mut names = BTreeSet::new();
|
||||
for pin in &input.plugins {
|
||||
for tool in &pin.projection.tools {
|
||||
let name = format!("{}__{}", pin.installation_id, tool);
|
||||
if name.len() > 128 || !names.insert(name) {
|
||||
return invalid();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
format_version: 1,
|
||||
input,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn input(&self) -> &WorkerPluginPlanInput {
|
||||
&self.input
|
||||
}
|
||||
|
||||
pub fn workspace_id(&self) -> &str {
|
||||
&self.input.workspace_id
|
||||
}
|
||||
pub fn config(&self) -> &ConfigProvenance {
|
||||
&self.input.config
|
||||
}
|
||||
pub fn profile(&self) -> &ProfileProvenance {
|
||||
&self.input.profile
|
||||
}
|
||||
pub fn execution(&self) -> &HostExecutionIdentity {
|
||||
&self.input.execution
|
||||
}
|
||||
pub fn plugins(&self) -> &[WorkerPluginPin] {
|
||||
&self.input.plugins
|
||||
}
|
||||
pub fn fingerprint(&self) -> Result<String> {
|
||||
digest_json(self)
|
||||
}
|
||||
pub fn canonical_json(&self) -> Result<String> {
|
||||
encode_canonical(self)
|
||||
}
|
||||
|
||||
pub fn validate_canonical(&self) -> Result<()> {
|
||||
if self.format_version != 1 || Self::canonicalize(self.input.clone())? != *self {
|
||||
return invalid();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn invalid<T>() -> Result<T> {
|
||||
Err(PlanError::Invalid)
|
||||
}
|
||||
fn conflict<T>() -> Result<T> {
|
||||
Err(PlanError::Conflict)
|
||||
}
|
||||
fn valid_identity(value: &str) -> bool {
|
||||
!value.is_empty() && value.len() <= 512 && !value.chars().any(char::is_control)
|
||||
}
|
||||
fn valid_digest(value: &str) -> bool {
|
||||
value.strip_prefix("sha256:").is_some_and(|hex| {
|
||||
hex.len() == 64
|
||||
&& hex
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
})
|
||||
}
|
||||
fn canonical_value(value: &Value) -> Value {
|
||||
match value {
|
||||
Value::Object(fields) => {
|
||||
let mut keys: Vec<_> = fields.keys().collect();
|
||||
keys.sort();
|
||||
Value::Object(
|
||||
keys.into_iter()
|
||||
.map(|key| (key.clone(), canonical_value(&fields[key])))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
Value::Array(values) => Value::Array(values.iter().map(canonical_value).collect()),
|
||||
_ => value.clone(),
|
||||
}
|
||||
}
|
||||
fn encode_canonical(value: &impl Serialize) -> Result<String> {
|
||||
let value = serde_json::to_value(value).map_err(|_| PlanError::Invalid)?;
|
||||
serde_json::to_string(&canonical_value(&value)).map_err(|_| PlanError::Invalid)
|
||||
}
|
||||
fn digest_json(value: &impl Serialize) -> Result<String> {
|
||||
let bytes = encode_canonical(value)?;
|
||||
Ok(hash_bytes(bytes.as_bytes()))
|
||||
}
|
||||
fn hash_bytes(bytes: &[u8]) -> String {
|
||||
let hex: String = Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect();
|
||||
format!("sha256:{hex}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,286 @@
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn digest(hex: char) -> String {
|
||||
format!("sha256:{}", hex.to_string().repeat(64))
|
||||
}
|
||||
|
||||
fn artifact() -> ArtifactIdentityProjection {
|
||||
ArtifactIdentityProjection {
|
||||
package_id: "package".into(),
|
||||
version: "1.0.0".into(),
|
||||
package_digest: digest('a'),
|
||||
artifact_digest: digest('b'),
|
||||
artifact_size: 42,
|
||||
schema_version: 2,
|
||||
world: plugin::PLUGIN_COMPONENT_HTTPS_LOGGED_TOOL_WORLD.into(),
|
||||
required_imports: vec!["logging".into(), "https".into(), "logging".into()],
|
||||
services: vec![],
|
||||
tools: vec!["other".into(), "echo".into()],
|
||||
connections: vec!["api".into()],
|
||||
requested_grants: vec![
|
||||
PluginGrant::Https { slot: "api".into() },
|
||||
PluginGrant::Logging,
|
||||
],
|
||||
config_schema: PluginSchemaIdentity {
|
||||
identity: "config".into(),
|
||||
version: "1".into(),
|
||||
digest: digest('c'),
|
||||
namespace: "example".into(),
|
||||
},
|
||||
revoked: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn projection() -> PluginPinProjection {
|
||||
PluginPinProjection {
|
||||
config: serde_json::from_str(r#"{"z":{"b":2,"a":1},"a":[{"z":0,"a":false}]}"#).unwrap(),
|
||||
surfaces: vec![PluginSurface::Tool, PluginSurface::Tool],
|
||||
services: vec![],
|
||||
tools: vec!["other".into(), "echo".into(), "other".into()],
|
||||
grants: vec![
|
||||
PluginGrant::Https { slot: "api".into() },
|
||||
PluginGrant::Logging,
|
||||
],
|
||||
bindings: vec![BindingPolicyReference {
|
||||
slot: "api".into(),
|
||||
binding_id: "binding".into(),
|
||||
policy_digest: digest('f'),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn pin(
|
||||
artifact: &impl ArtifactDescriptor,
|
||||
projection: PluginPinProjection,
|
||||
) -> Result<WorkerPluginPin> {
|
||||
WorkerPluginPin::from_artifact(
|
||||
"installation".into(),
|
||||
"operation".into(),
|
||||
artifact,
|
||||
projection,
|
||||
)
|
||||
}
|
||||
|
||||
fn input() -> WorkerPluginPlanInput {
|
||||
WorkerPluginPlanInput {
|
||||
workspace_id: "workspace".into(),
|
||||
config: ConfigProvenance {
|
||||
content_digest: digest('d'),
|
||||
toolchain_fingerprint: "toolchain".into(),
|
||||
},
|
||||
profile: ProfileProvenance {
|
||||
identity: "profile:coder".into(),
|
||||
projection_digest: digest('e'),
|
||||
},
|
||||
execution: HostExecutionIdentity {
|
||||
runtime_id: "runtime".into(),
|
||||
worker_id: "worker".into(),
|
||||
principal_id: "principal".into(),
|
||||
delegation_id: Some("delegation".into()),
|
||||
},
|
||||
plugins: vec![pin(&artifact(), projection()).unwrap()],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_wire_and_fingerprint_preserve_persisted_format() {
|
||||
let plan = WorkerPluginExecutionPlan::canonicalize(input()).unwrap();
|
||||
let expected = include_str!("fixtures/canonical-plan.json").trim_end();
|
||||
assert_eq!(plan.canonical_json().unwrap(), expected);
|
||||
assert_eq!(
|
||||
plan.fingerprint().unwrap(),
|
||||
"sha256:00ee15d79098f4afa137da5d4b0b9b78acde48a6157fdda0f3327d8c122bd889"
|
||||
);
|
||||
let restored: WorkerPluginExecutionPlan = serde_json::from_str(expected).unwrap();
|
||||
restored.validate_canonical().unwrap();
|
||||
assert_eq!(restored, plan);
|
||||
assert!(restored.plugins()[0].matches_artifact(&artifact()).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_identity_changes_are_not_hidden_by_projection_adapter() {
|
||||
let original = pin(&artifact(), projection()).unwrap();
|
||||
let mut changed = artifact();
|
||||
changed.artifact_size += 1;
|
||||
assert!(!original.matches_artifact(&changed).unwrap());
|
||||
changed = artifact();
|
||||
changed.config_schema.digest = digest('d');
|
||||
assert!(!original.matches_artifact(&changed).unwrap());
|
||||
changed = artifact();
|
||||
changed.revoked = true;
|
||||
assert_eq!(
|
||||
original.matches_artifact(&changed),
|
||||
Err(PlanError::Conflict)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_or_malformed_artifact_identity_is_invalid() {
|
||||
for change in [
|
||||
|a: &mut ArtifactIdentityProjection| a.package_digest = "sha256:ABC".into(),
|
||||
|a: &mut ArtifactIdentityProjection| a.artifact_digest = "digest".into(),
|
||||
|a: &mut ArtifactIdentityProjection| a.artifact_size = 0,
|
||||
|a: &mut ArtifactIdentityProjection| a.schema_version = 1,
|
||||
|a: &mut ArtifactIdentityProjection| a.world = "unsupported".into(),
|
||||
|a: &mut ArtifactIdentityProjection| a.package_id.clear(),
|
||||
|a: &mut ArtifactIdentityProjection| a.version = "bad\nversion".into(),
|
||||
|a: &mut ArtifactIdentityProjection| a.config_schema.identity.clear(),
|
||||
|a: &mut ArtifactIdentityProjection| a.config_schema.version.clear(),
|
||||
|a: &mut ArtifactIdentityProjection| a.config_schema.digest.clear(),
|
||||
|a: &mut ArtifactIdentityProjection| a.config_schema.namespace.clear(),
|
||||
] {
|
||||
let mut value = artifact();
|
||||
change(&mut value);
|
||||
assert_eq!(pin(&value, projection()), Err(PlanError::Invalid));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_tools_grants_and_bindings_cannot_expand_artifact_metadata() {
|
||||
for change in [
|
||||
|p: &mut PluginPinProjection| p.config = json!([]),
|
||||
|p: &mut PluginPinProjection| p.surfaces.clear(),
|
||||
|p: &mut PluginPinProjection| p.tools.push("absent".into()),
|
||||
|p: &mut PluginPinProjection| p.bindings.push(p.bindings[0].clone()),
|
||||
|p: &mut PluginPinProjection| p.bindings[0].slot = "absent".into(),
|
||||
|p: &mut PluginPinProjection| p.bindings[0].binding_id.clear(),
|
||||
|p: &mut PluginPinProjection| p.bindings[0].policy_digest.clear(),
|
||||
|p: &mut PluginPinProjection| p.bindings.clear(),
|
||||
|p: &mut PluginPinProjection| p.grants.retain(|g| *g == PluginGrant::Logging),
|
||||
] {
|
||||
let mut value = projection();
|
||||
change(&mut value);
|
||||
assert_eq!(pin(&artifact(), value), Err(PlanError::Invalid));
|
||||
}
|
||||
for change in [
|
||||
|a: &mut ArtifactIdentityProjection| a.tools.clear(),
|
||||
|a: &mut ArtifactIdentityProjection| a.connections.clear(),
|
||||
|a: &mut ArtifactIdentityProjection| a.requested_grants.clear(),
|
||||
] {
|
||||
let mut value = artifact();
|
||||
change(&mut value);
|
||||
assert_eq!(pin(&value, projection()), Err(PlanError::Invalid));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_provenance_and_duplicate_installations_are_invalid() {
|
||||
for change in [
|
||||
|i: &mut WorkerPluginPlanInput| i.workspace_id.clear(),
|
||||
|i: &mut WorkerPluginPlanInput| i.config.content_digest.clear(),
|
||||
|i: &mut WorkerPluginPlanInput| i.config.toolchain_fingerprint.clear(),
|
||||
|i: &mut WorkerPluginPlanInput| i.profile.identity.clear(),
|
||||
|i: &mut WorkerPluginPlanInput| i.profile.projection_digest.clear(),
|
||||
|i: &mut WorkerPluginPlanInput| i.execution.runtime_id.clear(),
|
||||
|i: &mut WorkerPluginPlanInput| i.execution.worker_id.clear(),
|
||||
|i: &mut WorkerPluginPlanInput| i.execution.principal_id.clear(),
|
||||
|i: &mut WorkerPluginPlanInput| i.execution.delegation_id = Some(String::new()),
|
||||
|i: &mut WorkerPluginPlanInput| i.plugins.push(i.plugins[0].clone()),
|
||||
|i: &mut WorkerPluginPlanInput| i.plugins = vec![i.plugins[0].clone(); 129],
|
||||
] {
|
||||
let mut value = input();
|
||||
change(&mut value);
|
||||
assert_eq!(
|
||||
WorkerPluginExecutionPlan::canonicalize(value),
|
||||
Err(PlanError::Invalid)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialized_unknown_fields_versions_and_noncanonical_order_are_rejected() {
|
||||
let plan = WorkerPluginExecutionPlan::canonicalize(input()).unwrap();
|
||||
let mut wire = serde_json::to_value(&plan).unwrap();
|
||||
wire["input"]["execution"]["token"] = json!("credential");
|
||||
assert!(serde_json::from_value::<WorkerPluginExecutionPlan>(wire).is_err());
|
||||
let mut wire = serde_json::to_value(&plan).unwrap();
|
||||
wire["format_version"] = json!(2);
|
||||
let unsupported: WorkerPluginExecutionPlan = serde_json::from_value(wire).unwrap();
|
||||
assert_eq!(unsupported.validate_canonical(), Err(PlanError::Invalid));
|
||||
let mut unsorted = plan.clone();
|
||||
let mut second = unsorted.input.plugins[0].clone();
|
||||
second.installation_id = "aaa".into();
|
||||
unsorted.input.plugins.push(second);
|
||||
assert_eq!(unsorted.validate_canonical(), Err(PlanError::Invalid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_grants_require_exact_world_request_and_schema_namespace() {
|
||||
let mut a = artifact();
|
||||
let write = PluginGrant::TicketDataWrite {
|
||||
collection: "notes".into(),
|
||||
schema_id: "note".into(),
|
||||
schema_version: "1".into(),
|
||||
};
|
||||
let read = PluginGrant::TicketDataRead {
|
||||
collection: "notes".into(),
|
||||
schema_id: "note".into(),
|
||||
schema_version: "1".into(),
|
||||
};
|
||||
a.requested_grants
|
||||
.extend([PluginGrant::TicketRead, write.clone()]);
|
||||
let mut selected = projection();
|
||||
selected.grants.push(write.clone());
|
||||
// Even a fabricated metadata request cannot add Ticket operations to v2.0.
|
||||
assert_eq!(pin(&a, selected.clone()), Err(PlanError::Invalid));
|
||||
a.world = plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD.into();
|
||||
let exact = pin(&a, selected.clone()).unwrap();
|
||||
assert!(exact.projection().grants.contains(&write));
|
||||
assert!(!exact.projection().grants.contains(&read)); // write is not read
|
||||
selected.grants.push(read);
|
||||
assert_eq!(pin(&a, selected), Err(PlanError::Invalid));
|
||||
let mut selected = projection();
|
||||
selected.grants.push(PluginGrant::TicketDataWrite {
|
||||
collection: "notes".into(),
|
||||
schema_id: "note".into(),
|
||||
schema_version: "2".into(),
|
||||
});
|
||||
assert_eq!(pin(&a, selected), Err(PlanError::Invalid));
|
||||
a.requested_grants.retain(|g| g != &write);
|
||||
assert_eq!(exact.matches_artifact(&a), Err(PlanError::Invalid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn service_selection_needs_exact_new_world_declared_name_and_explicit_event_grant() {
|
||||
let mut artifact = artifact();
|
||||
let mut selected = projection();
|
||||
selected.tools.clear();
|
||||
selected.bindings.clear();
|
||||
selected.grants = vec![PluginGrant::TicketEvents];
|
||||
selected.surfaces = vec![PluginSurface::Service];
|
||||
selected.services = vec!["watch".into()];
|
||||
artifact.services = vec!["watch".into()];
|
||||
artifact.requested_grants.push(PluginGrant::TicketEvents);
|
||||
assert_eq!(pin(&artifact, selected.clone()), Err(PlanError::Invalid));
|
||||
artifact.world = plugin::PLUGIN_COMPONENT_SERVICE_WORLD.into();
|
||||
let exact = pin(&artifact, selected.clone()).unwrap();
|
||||
selected.services.push("undeclared".into());
|
||||
assert_eq!(pin(&artifact, selected), Err(PlanError::Invalid));
|
||||
artifact.services.clear();
|
||||
assert_eq!(exact.matches_artifact(&artifact), Err(PlanError::Invalid));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_serialized_pin_does_not_acquire_service_selection_or_event_grant() {
|
||||
let plan: WorkerPluginExecutionPlan =
|
||||
serde_json::from_str(include_str!("fixtures/canonical-plan.json")).unwrap();
|
||||
assert!(plan.plugins()[0].projection().services.is_empty());
|
||||
assert!(
|
||||
!plan.plugins()[0]
|
||||
.projection()
|
||||
.surfaces
|
||||
.contains(&PluginSurface::Service)
|
||||
);
|
||||
assert!(
|
||||
!plan.plugins()[0]
|
||||
.projection()
|
||||
.grants
|
||||
.contains(&PluginGrant::TicketEvents)
|
||||
);
|
||||
// Additive default-off fields cannot mutate the exact old plan fingerprint.
|
||||
assert_eq!(
|
||||
plan.fingerprint().unwrap(),
|
||||
"sha256:00ee15d79098f4afa137da5d4b0b9b78acde48a6157fdda0f3327d8c122bd889"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
[package]
|
||||
name = "yoi-plugin-runtime"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
manifest.workspace = true
|
||||
server-api.workspace = true
|
||||
sha2.workspace = true
|
||||
fs4.workspace = true
|
||||
tempfile.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio = { workspace = true, features = ["rt", "macros", "sync", "time", "fs", "io-util"] }
|
||||
tokio-util = { version = "0.7", features = ["rt"] }
|
||||
jsonschema = { version = "0.33.0", default-features = false }
|
||||
wasmparser = "0.244.0"
|
||||
wasmtime = { version = "=38.0.4", default-features = false, features = ["async", "component-model", "cranelift", "runtime", "std", "threads"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
wit-component = { version = "0.244.0", features = ["dummy-module"] }
|
||||
wit-parser = "0.244.0"
|
||||
wasmprinter = "0.244.0"
|
||||
wat = "1"
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "test-util", "net"] }
|
||||
@@ -0,0 +1,15 @@
|
||||
# yoi-plugin-runtime
|
||||
|
||||
Runtime-owned verification, byte caching and standalone execution of exact versioned Tool and Service Components. [`Executor`] compiles only artifacts reverified through `manifest::plugin`, then creates a fresh Store and instance for each invocation. It does not resolve catalogs, config, Worker plans or repository paths, and does not register Tools with a Worker.
|
||||
|
||||
[`delivery`] caches canonical bytes under the authenticated Server authority and artifact digest. Its source adapter must reauthorize the exact saved plan selection on every hit and after transfer. Byte deduplication shares neither metadata nor installation/config/grant authority. A private Runtime directory, bounded staging, process-released locking and atomic publication prevent partial files from becoming executor inputs; abandoned staging is discarded under the same lock. Cache deletion never releases Server pins. The cache is not an offline execution lease, and verified bytes still require the execution Host's fresh admission.
|
||||
|
||||
[`host`] is an explicit asynchronous injection boundary, not an implementation of Server authority or HTTPS. Every invocation (including pure computation) requires live admission and result-publication authorization. Providers must bind their authenticated installation/plan/subject and narrowed grants to [`host::InvocationContext`], authorize every protected operation, and make pending work cancellation-safe. There is no default provider or product allow-all fallback. Dropping a provider future must release temporary resources; an already dispatched external effect is not rolled back. Durable effect receipts, secret resolution, URL/DNS/TLS policy and real network transport belong to the Host provider, not this sandbox.
|
||||
|
||||
Component instantiation is not an export: all Host imports, including logging, are rejected until the export context is installed. Service start/stop exports permit only logging; event exports reuse the same Host and Ticket/data resources as Tools. [`ServiceInvocationResult`] retains Host effect observations independently of guest acknowledgement, including a dispatch whose wait is cancelled. This observation is not a second receipt authority: the driver must pause on unknown effects and commit acknowledgement through its owner. Only exact WIT imports are linked, without WASI, filesystem, environment, process, clock or network ambient authority. Call-local connection resources hold opaque provider objects; guest resource representations never identify a Server resource or issue a grant.
|
||||
|
||||
A single deadline covers admission, instantiation, guest execution, asynchronous Host waits, post-return and publication. Fuel bounds computation and cooperatively yields; epoch interruption cooperatively yields as well. Store memory/table/instance limits, call-local resource and operation counts, serialized payload and diagnostic budgets complement those bounds. Text/JSON overflows fail rather than returning truncated success; excess diagnostic entries are dropped and counted. Guest diagnostics are untrusted, must not contain secrets, and are separate from model output. Host adapters must not log raw request/response or credentials.
|
||||
|
||||
The sandbox accepts only imported Host resources. Wasmtime's Store limiter does not meter guest-defined Component resource tables, so compile rejects private resource definitions and canonical `resource.new` throughout concrete nested Components before any Store exists. This is an additional execution-policy restriction after shared package/ABI verification, not a second package validator. It preserves the WIT connection imports, whose creation is metered by the adapter, without claiming a quota for unmetered internal resource allocators. Unsupported execution policy is distinct from corrupt artifact identity.
|
||||
|
||||
Compile results may be shared, but invocation memory, resource tables and imported state are never shared. The caller owns finite aggregate admission/concurrency quotas; per-invocation limits alone do not bound an unbounded number of callers. A validated digest is content identity, never admission authority. Product integration must supply the live Server provider and owns Worker launch/restore and Service driver lifetime.
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Runtime-owned byte cache, not a source of admission, configuration or grants.
|
||||
//!
|
||||
//! The caller supplies a private Runtime directory and an expectation from its
|
||||
//! trusted Host plan, never a model-selected path or owner-supplied expectation.
|
||||
//! Every resolution checks the live owner twice, including hits. The returned
|
||||
//! bytes prove format/identity only; execution still requires current admission.
|
||||
//! A process-released filesystem lock bounds all final and pending files together.
|
||||
//! Only synced, completely verified files are renamed into the visible namespace;
|
||||
//! abandoned staging files are discarded under that same lock after restart.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use manifest::plugin::{self, PluginPackageLimits};
|
||||
use server_api::{PluginArtifact, PluginArtifactDeliveryMetadata, PluginArtifactDeliveryRequest};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
fs::{self, File, OpenOptions},
|
||||
io::{self, Read},
|
||||
path::{Path, PathBuf},
|
||||
pin::Pin,
|
||||
time::Duration,
|
||||
};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
pub type ArtifactStream = Pin<Box<dyn AsyncRead + Send + Unpin>>;
|
||||
|
||||
#[async_trait]
|
||||
pub trait ArtifactSource: Send + Sync {
|
||||
/// Stable authenticated Server authority, not a workspace or connection ID.
|
||||
fn server_authority_identity(&self) -> &str;
|
||||
async fn authorize(
|
||||
&self,
|
||||
request: &PluginArtifactDeliveryRequest,
|
||||
) -> Result<PluginArtifactDeliveryMetadata, DeliveryError>;
|
||||
async fn fetch(
|
||||
&self,
|
||||
request: &PluginArtifactDeliveryRequest,
|
||||
) -> Result<ArtifactStream, DeliveryError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum DeliveryError {
|
||||
#[error("artifact delivery denied by current owner")]
|
||||
Denied,
|
||||
#[error("artifact owner unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
#[error("invalid artifact delivery request")]
|
||||
InvalidRequest,
|
||||
#[error("artifact metadata differs from trusted Host plan")]
|
||||
MetadataMismatch,
|
||||
#[error("artifact exceeds delivery bound or declared size")]
|
||||
TooLarge,
|
||||
#[error("invalid artifact: {0}")]
|
||||
InvalidArtifact(String),
|
||||
#[error("cache directory must be private and Runtime-owned")]
|
||||
InvalidCacheRoot,
|
||||
#[error("invalid artifact cache limits")]
|
||||
InvalidLimits,
|
||||
#[error("artifact delivery deadline exceeded")]
|
||||
Timeout,
|
||||
#[error("artifact cache I/O failed: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
}
|
||||
|
||||
/// Exact immutable descriptor selected by the Host, not metadata learned from a
|
||||
/// delivery response. Keeping the complete descriptor also binds Tool schemas,
|
||||
/// connection declarations, requested (not effective) grants and schema identity.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ArtifactExpectation {
|
||||
artifact: PluginArtifact,
|
||||
}
|
||||
|
||||
impl ArtifactExpectation {
|
||||
pub fn new(host_plan_artifact: PluginArtifact) -> Self {
|
||||
Self {
|
||||
artifact: host_plan_artifact,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn descriptor(&self) -> &PluginArtifact {
|
||||
&self.artifact
|
||||
}
|
||||
}
|
||||
|
||||
/// Bytes have no public constructor or mutable accessor, and contain no cached
|
||||
/// owner authorization, Worker configuration, effective grants or pin ownership.
|
||||
pub struct VerifiedArtifact {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for VerifiedArtifact {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("VerifiedArtifact")
|
||||
.field("byte_length", &self.bytes.len())
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl VerifiedArtifact {
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
&self.bytes
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CacheLimits {
|
||||
pub package: PluginPackageLimits,
|
||||
/// Includes the current staging download, not only published files.
|
||||
pub max_cache_bytes: u64,
|
||||
pub max_entries: usize,
|
||||
pub delivery_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Default for CacheLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
package: PluginPackageLimits::default(),
|
||||
max_cache_bytes: 256 * 1024 * 1024,
|
||||
max_entries: 32,
|
||||
delivery_timeout: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ArtifactCache {
|
||||
root: PathBuf,
|
||||
limits: CacheLimits,
|
||||
}
|
||||
|
||||
impl ArtifactCache {
|
||||
/// The root belongs exclusively to the Runtime cache. Existing roots must
|
||||
/// already be private; this never fixes permissions on arbitrary directories.
|
||||
pub fn new(
|
||||
runtime_owned_root: impl AsRef<Path>,
|
||||
limits: CacheLimits,
|
||||
) -> Result<Self, DeliveryError> {
|
||||
if limits.max_cache_bytes == 0
|
||||
|| limits.max_entries == 0
|
||||
|| limits.package.max_package_size_bytes == 0
|
||||
|| limits.delivery_timeout.is_zero()
|
||||
{
|
||||
return Err(DeliveryError::InvalidLimits);
|
||||
}
|
||||
let root = runtime_owned_root.as_ref();
|
||||
let mut builder = fs::DirBuilder::new();
|
||||
builder.recursive(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
builder.create(root)?;
|
||||
let metadata = fs::symlink_metadata(root)?;
|
||||
if !metadata.is_dir() || metadata.file_type().is_symlink() {
|
||||
return Err(DeliveryError::InvalidCacheRoot);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if metadata.permissions().mode() & 0o077 != 0 {
|
||||
return Err(DeliveryError::InvalidCacheRoot);
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
root: fs::canonicalize(root)?,
|
||||
limits,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn resolve(
|
||||
&self,
|
||||
source: &dyn ArtifactSource,
|
||||
trusted_workspace_id: &str,
|
||||
request: &PluginArtifactDeliveryRequest,
|
||||
expected: &ArtifactExpectation,
|
||||
) -> Result<VerifiedArtifact, DeliveryError> {
|
||||
tokio::time::timeout(
|
||||
self.limits.delivery_timeout,
|
||||
self.resolve_inner(source, trusted_workspace_id, request, expected),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| DeliveryError::Timeout)?
|
||||
}
|
||||
|
||||
async fn resolve_inner(
|
||||
&self,
|
||||
source: &dyn ArtifactSource,
|
||||
workspace: &str,
|
||||
request: &PluginArtifactDeliveryRequest,
|
||||
expected: &ArtifactExpectation,
|
||||
) -> Result<VerifiedArtifact, DeliveryError> {
|
||||
let authority = source.server_authority_identity().to_owned();
|
||||
let descriptor = &expected.artifact;
|
||||
if authority.is_empty()
|
||||
|| workspace.is_empty()
|
||||
|| request.plan_id.is_empty()
|
||||
|| request.plan_fingerprint.is_empty()
|
||||
|| request.installation_id.is_empty()
|
||||
|| !is_digest(&request.artifact_digest)
|
||||
|| request.artifact_digest != descriptor.artifact_digest
|
||||
|| !is_digest(&descriptor.package_digest)
|
||||
{
|
||||
return Err(DeliveryError::InvalidRequest);
|
||||
}
|
||||
let size = u64::from(descriptor.artifact_size);
|
||||
if size == 0
|
||||
|| size > self.limits.package.max_package_size_bytes
|
||||
|| size > self.limits.max_cache_bytes
|
||||
{
|
||||
return Err(DeliveryError::TooLarge);
|
||||
}
|
||||
self.authorize(source, &authority, workspace, request, expected)
|
||||
.await?;
|
||||
|
||||
// Lock ownership follows this future, including cancellation/deadline.
|
||||
let _lock = lock_cache(self.root.join(".cache.lock")).await?;
|
||||
let mut entries = self.inventory()?;
|
||||
let path = self.root.join(format!(
|
||||
"{}-{}.artifact",
|
||||
hash(authority.as_bytes()),
|
||||
&request.artifact_digest[7..]
|
||||
));
|
||||
let bytes = match self.read_verified(&path, expected)? {
|
||||
Some(bytes) => {
|
||||
self.make_room(&mut entries, Some(&path), 0, 0)?;
|
||||
bytes
|
||||
}
|
||||
None => {
|
||||
// Discard a corrupt final file only while holding the cache lock;
|
||||
// no competing publisher can have repaired it since the read.
|
||||
if let Some(index) = entries.iter().position(|entry| entry.path == path) {
|
||||
fs::remove_file(&path)?;
|
||||
entries.remove(index);
|
||||
}
|
||||
self.make_room(&mut entries, None, size, 1)?;
|
||||
let mut stream = source.fetch(request).await?;
|
||||
if source.server_authority_identity() != authority {
|
||||
return Err(DeliveryError::MetadataMismatch);
|
||||
}
|
||||
let pending = tempfile::Builder::new()
|
||||
.prefix(".pending-")
|
||||
.tempfile_in(&self.root)?;
|
||||
let mut file = tokio::fs::File::from_std(pending.reopen()?);
|
||||
let mut written = 0_u64;
|
||||
let mut buffer = [0_u8; 64 * 1024];
|
||||
loop {
|
||||
// Read at most one byte beyond the declaration, even if the
|
||||
// peer streams forever. Excess bytes are never written.
|
||||
let remaining = (size - written + 1).min(buffer.len() as u64) as usize;
|
||||
let n = stream.read(&mut buffer[..remaining]).await?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
written += n as u64;
|
||||
if written > size {
|
||||
return Err(DeliveryError::TooLarge);
|
||||
}
|
||||
file.write_all(&buffer[..n]).await?;
|
||||
}
|
||||
if written != size {
|
||||
return Err(DeliveryError::InvalidArtifact(
|
||||
"truncated artifact stream".into(),
|
||||
));
|
||||
}
|
||||
file.flush().await?;
|
||||
file.sync_all().await?;
|
||||
drop(file);
|
||||
let bytes = self.read_exact_bounded(pending.path(), size)?;
|
||||
self.verify(&bytes, expected)?;
|
||||
// tempfile persist is an atomic same-filesystem rename. The global
|
||||
// lock prevents overwrite races; no final name ever exposes a prefix.
|
||||
pending.persist(&path).map_err(|error| error.error)?;
|
||||
sync_directory(&self.root)?;
|
||||
bytes
|
||||
}
|
||||
};
|
||||
// Neither disk presence nor authorization before transfer proves current
|
||||
// owner policy after a slow download, eviction wait or cached verification.
|
||||
self.authorize(source, &authority, workspace, request, expected)
|
||||
.await?;
|
||||
Ok(VerifiedArtifact { bytes })
|
||||
}
|
||||
|
||||
async fn authorize(
|
||||
&self,
|
||||
source: &dyn ArtifactSource,
|
||||
authority: &str,
|
||||
workspace: &str,
|
||||
request: &PluginArtifactDeliveryRequest,
|
||||
expected: &ArtifactExpectation,
|
||||
) -> Result<(), DeliveryError> {
|
||||
let metadata = source.authorize(request).await?;
|
||||
if metadata.artifact.revoked || expected.artifact.revoked {
|
||||
return Err(DeliveryError::Denied);
|
||||
}
|
||||
if source.server_authority_identity() != authority
|
||||
|| metadata.workspace_id != workspace
|
||||
|| metadata.request != *request
|
||||
|| metadata.artifact != expected.artifact
|
||||
{
|
||||
return Err(DeliveryError::MetadataMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_verified(
|
||||
&self,
|
||||
path: &Path,
|
||||
expected: &ArtifactExpectation,
|
||||
) -> Result<Option<Vec<u8>>, DeliveryError> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(DeliveryError::InvalidCacheRoot);
|
||||
}
|
||||
if metadata.len() != u64::from(expected.artifact.artifact_size) {
|
||||
return Ok(None);
|
||||
}
|
||||
let bytes = self.read_exact_bounded(path, metadata.len())?;
|
||||
match self.verify(&bytes, expected) {
|
||||
Ok(()) => Ok(Some(bytes)),
|
||||
Err(DeliveryError::InvalidArtifact(_)) => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_exact_bounded(&self, path: &Path, size: u64) -> Result<Vec<u8>, DeliveryError> {
|
||||
let mut bytes = Vec::new();
|
||||
File::open(path)?.take(size + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() as u64 != size {
|
||||
return Err(DeliveryError::InvalidArtifact(
|
||||
"cached artifact size changed".into(),
|
||||
));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn verify(&self, bytes: &[u8], expected: &ArtifactExpectation) -> Result<(), DeliveryError> {
|
||||
let d = &expected.artifact;
|
||||
let package = plugin::verify_plugin_artifact(
|
||||
bytes,
|
||||
&d.package_digest,
|
||||
&d.artifact_digest,
|
||||
u64::from(d.artifact_size),
|
||||
&self.limits.package,
|
||||
)
|
||||
.map_err(|error| DeliveryError::InvalidArtifact(error.message))?;
|
||||
let m = &package.package.manifest;
|
||||
let schema = plugin::plugin_config_schema_identity(m);
|
||||
if d.package_id != m.id
|
||||
|| d.name != m.name
|
||||
|| d.version != m.version
|
||||
|| d.description != m.description
|
||||
|| d.schema_version != m.schema_version
|
||||
|| d.world != m.runtime.world
|
||||
|| d.required_imports != m.runtime.required_imports
|
||||
|| !same_json(&d.tools, &m.tools)?
|
||||
|| !same_json(&d.services, &m.services)?
|
||||
|| !same_json(&d.connections, &m.connections)?
|
||||
|| !same_json(&d.requested_grants, &m.requested_grants)?
|
||||
|| !same_json(&d.data_collections, &m.data_collections)?
|
||||
|| d.config_schema.dcdl != m.config_schema
|
||||
|| d.config_schema.digest != schema.digest
|
||||
|| d.config_schema.identity != schema.identity
|
||||
|| d.config_schema.version != schema.version
|
||||
|| d.config_schema.namespace != schema.namespace
|
||||
{
|
||||
return Err(DeliveryError::MetadataMismatch);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn inventory(&self) -> Result<Vec<Entry>, DeliveryError> {
|
||||
let mut entries = Vec::new();
|
||||
for entry in fs::read_dir(&self.root)? {
|
||||
let entry = entry?;
|
||||
let name = entry.file_name();
|
||||
let name = name.to_str().ok_or(DeliveryError::InvalidCacheRoot)?;
|
||||
let metadata = fs::symlink_metadata(entry.path())?;
|
||||
if !metadata.is_file() || metadata.file_type().is_symlink() {
|
||||
return Err(DeliveryError::InvalidCacheRoot);
|
||||
}
|
||||
if name == ".cache.lock" {
|
||||
continue;
|
||||
}
|
||||
if name.starts_with(".pending-") {
|
||||
fs::remove_file(entry.path())?;
|
||||
continue;
|
||||
}
|
||||
if !is_cache_filename(name) {
|
||||
return Err(DeliveryError::InvalidCacheRoot);
|
||||
}
|
||||
entries.push(Entry {
|
||||
path: entry.path(),
|
||||
size: metadata.len(),
|
||||
modified: metadata.modified()?,
|
||||
});
|
||||
}
|
||||
// Oldest publication first; hits do not need a second metadata ledger.
|
||||
entries.sort_by(|a, b| a.modified.cmp(&b.modified).then(a.path.cmp(&b.path)));
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn make_room(
|
||||
&self,
|
||||
entries: &mut Vec<Entry>,
|
||||
keep: Option<&Path>,
|
||||
additional_bytes: u64,
|
||||
additional_entries: usize,
|
||||
) -> Result<(), DeliveryError> {
|
||||
let mut bytes = entries.iter().try_fold(additional_bytes, |sum, entry| {
|
||||
sum.checked_add(entry.size).ok_or(DeliveryError::TooLarge)
|
||||
})?;
|
||||
let mut count = entries.len() + additional_entries;
|
||||
for entry in entries.iter() {
|
||||
if bytes <= self.limits.max_cache_bytes && count <= self.limits.max_entries {
|
||||
break;
|
||||
}
|
||||
if keep == Some(entry.path.as_path()) {
|
||||
continue;
|
||||
}
|
||||
fs::remove_file(&entry.path)?;
|
||||
bytes -= entry.size;
|
||||
count -= 1;
|
||||
}
|
||||
if bytes > self.limits.max_cache_bytes || count > self.limits.max_entries {
|
||||
return Err(DeliveryError::TooLarge);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
path: PathBuf,
|
||||
size: u64,
|
||||
modified: std::time::SystemTime,
|
||||
}
|
||||
|
||||
async fn lock_cache(path: PathBuf) -> Result<File, DeliveryError> {
|
||||
if let Ok(metadata) = fs::symlink_metadata(&path)
|
||||
&& (!metadata.is_file() || metadata.file_type().is_symlink())
|
||||
{
|
||||
return Err(DeliveryError::InvalidCacheRoot);
|
||||
}
|
||||
let mut options = OpenOptions::new();
|
||||
options.create(true).truncate(false).read(true).write(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let file = options.open(path)?;
|
||||
loop {
|
||||
if fs4::fs_std::FileExt::try_lock_exclusive(&file)? {
|
||||
return Ok(file);
|
||||
}
|
||||
// OS locks provide crash recovery; nonblocking acquisition keeps cancelled
|
||||
// waiters from occupying Tokio blocking threads after their caller exits.
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_directory(root: &Path) -> Result<(), DeliveryError> {
|
||||
// Windows does not expose directory fsync through std::fs::File. Atomic
|
||||
// visibility still holds there; power-loss directory durability is Unix-only.
|
||||
#[cfg(unix)]
|
||||
File::open(root)?.sync_all()?;
|
||||
#[cfg(not(unix))]
|
||||
let _ = root;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hash(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes)
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_hex(value: &str) -> bool {
|
||||
value.len() == 64
|
||||
&& value
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}
|
||||
|
||||
fn is_digest(value: &str) -> bool {
|
||||
value.strip_prefix("sha256:").is_some_and(is_hex)
|
||||
}
|
||||
|
||||
fn is_cache_filename(value: &str) -> bool {
|
||||
value
|
||||
.strip_suffix(".artifact")
|
||||
.and_then(|value| value.split_once('-'))
|
||||
.is_some_and(|(authority, digest)| is_hex(authority) && is_hex(digest))
|
||||
}
|
||||
|
||||
fn same_json<A: serde::Serialize, B: serde::Serialize>(
|
||||
a: &A,
|
||||
b: &B,
|
||||
) -> Result<bool, DeliveryError> {
|
||||
let a = serde_json::to_value(a).map_err(|_| DeliveryError::MetadataMismatch)?;
|
||||
let b = serde_json::to_value(b).map_err(|_| DeliveryError::MetadataMismatch)?;
|
||||
Ok(a == b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,996 @@
|
||||
//! Cache/real filesystem/shared Component package integration. Only owner policy
|
||||
//! and transport are fixtures; canonical verification, streaming limits, locking,
|
||||
//! eviction and publication use production code. Subprocess actors prove lock
|
||||
//! convergence and process-exit recovery, not arbitrary power-loss durability.
|
||||
|
||||
use super::*;
|
||||
use manifest::plugin::{CanonicalPluginArtifact, PluginSourceKind};
|
||||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
process::{Child, Command, Stdio},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
task::{Context, Poll},
|
||||
};
|
||||
use tokio::{io::ReadBuf, sync::Semaphore};
|
||||
|
||||
const MANIFEST: &str =
|
||||
include_str!("../../../../resources/plugin/templates/rust-component-tool/plugin.toml");
|
||||
const COMPONENT: &[u8] =
|
||||
include_bytes!("../../../plugin-pdk/tests/fixtures/tool-v2.component.wasm");
|
||||
|
||||
fn fixture() -> (CanonicalPluginArtifact, PluginArtifact) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::write(directory.path().join("plugin.toml"), MANIFEST).unwrap();
|
||||
fs::write(directory.path().join("plugin.component.wasm"), COMPONENT).unwrap();
|
||||
let limits = PluginPackageLimits::default();
|
||||
let package =
|
||||
plugin::read_plugin_directory(directory.path(), PluginSourceKind::Explicit, &limits)
|
||||
.unwrap();
|
||||
let artifact = plugin::pack_plugin_package(&package, &limits).unwrap();
|
||||
let m = package.package.manifest;
|
||||
let schema_digest = format!("sha256:{}", hash(m.config_schema.as_bytes()));
|
||||
let descriptor = PluginArtifact {
|
||||
package_id: m.id.clone(),
|
||||
name: m.name,
|
||||
version: m.version.clone(),
|
||||
description: m.description,
|
||||
package_digest: artifact.package_digest.clone(),
|
||||
artifact_digest: artifact.artifact_digest.clone(),
|
||||
artifact_size: artifact.artifact_size.try_into().unwrap(),
|
||||
schema_version: m.schema_version,
|
||||
world: m.runtime.world,
|
||||
required_imports: m.runtime.required_imports,
|
||||
tools: serde_json::from_value(serde_json::to_value(m.tools).unwrap()).unwrap(),
|
||||
services: vec![],
|
||||
connections: vec![],
|
||||
requested_grants: vec![],
|
||||
data_collections: vec![],
|
||||
config_schema: server_api::PluginSchemaContribution {
|
||||
identity: format!("{}/config", m.id),
|
||||
version: m.version.clone(),
|
||||
namespace: format!("{}/config/{}/{}", m.id, m.version, schema_digest),
|
||||
digest: schema_digest,
|
||||
dcdl: m.config_schema,
|
||||
},
|
||||
revoked: false,
|
||||
revocation_reason: None,
|
||||
};
|
||||
(artifact, descriptor)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Transfer {
|
||||
Complete,
|
||||
Short,
|
||||
Error,
|
||||
Pause,
|
||||
Crash,
|
||||
}
|
||||
|
||||
struct Source {
|
||||
authority: String,
|
||||
metadata: Mutex<PluginArtifactDeliveryMetadata>,
|
||||
bytes: Vec<u8>,
|
||||
fetches: AtomicUsize,
|
||||
authorizations: AtomicUsize,
|
||||
deny_on: AtomicUsize,
|
||||
unavailable: AtomicUsize,
|
||||
transfer: Transfer,
|
||||
read_bytes: Arc<AtomicUsize>,
|
||||
paused: Arc<Semaphore>,
|
||||
}
|
||||
|
||||
impl Source {
|
||||
fn new(artifact: &CanonicalPluginArtifact, descriptor: PluginArtifact) -> Self {
|
||||
Self {
|
||||
authority: "authenticated-server-a".into(),
|
||||
metadata: Mutex::new(PluginArtifactDeliveryMetadata {
|
||||
workspace_id: "workspace-a".into(),
|
||||
request: PluginArtifactDeliveryRequest {
|
||||
plan_id: "plan-a".into(),
|
||||
plan_fingerprint: "exact-plan-fingerprint".into(),
|
||||
installation_id: "installation-a".into(),
|
||||
artifact_digest: artifact.artifact_digest.clone(),
|
||||
},
|
||||
artifact: descriptor,
|
||||
}),
|
||||
bytes: artifact.bytes.clone(),
|
||||
fetches: AtomicUsize::new(0),
|
||||
authorizations: AtomicUsize::new(0),
|
||||
deny_on: AtomicUsize::new(0),
|
||||
unavailable: AtomicUsize::new(0),
|
||||
transfer: Transfer::Complete,
|
||||
read_bytes: Arc::new(AtomicUsize::new(0)),
|
||||
paused: Arc::new(Semaphore::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&self) -> PluginArtifactDeliveryRequest {
|
||||
self.metadata.lock().unwrap().request.clone()
|
||||
}
|
||||
|
||||
fn expectation(&self) -> ArtifactExpectation {
|
||||
ArtifactExpectation::new(self.metadata.lock().unwrap().artifact.clone())
|
||||
}
|
||||
|
||||
async fn resolve(&self, cache: &ArtifactCache) -> Result<VerifiedArtifact, DeliveryError> {
|
||||
cache
|
||||
.resolve(self, "workspace-a", &self.request(), &self.expectation())
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ArtifactSource for Source {
|
||||
fn server_authority_identity(&self) -> &str {
|
||||
&self.authority
|
||||
}
|
||||
|
||||
async fn authorize(
|
||||
&self,
|
||||
_: &PluginArtifactDeliveryRequest,
|
||||
) -> Result<PluginArtifactDeliveryMetadata, DeliveryError> {
|
||||
let call = self.authorizations.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
if self.unavailable.load(Ordering::SeqCst) != 0 {
|
||||
return Err(DeliveryError::Unavailable("offline fixture".into()));
|
||||
}
|
||||
if call == self.deny_on.load(Ordering::SeqCst) {
|
||||
return Err(DeliveryError::Denied);
|
||||
}
|
||||
Ok(self.metadata.lock().unwrap().clone())
|
||||
}
|
||||
|
||||
async fn fetch(
|
||||
&self,
|
||||
_: &PluginArtifactDeliveryRequest,
|
||||
) -> Result<ArtifactStream, DeliveryError> {
|
||||
self.fetches.fetch_add(1, Ordering::SeqCst);
|
||||
// A real bounded in-memory transport pipe fragments successful downloads.
|
||||
// Fault streams replace only that transport, not the filesystem/cache.
|
||||
if matches!(self.transfer, Transfer::Complete | Transfer::Short) {
|
||||
let (reader, mut writer) = tokio::io::duplex(127);
|
||||
let mut bytes = self.bytes.clone();
|
||||
if matches!(self.transfer, Transfer::Short) {
|
||||
bytes.truncate(bytes.len() / 2);
|
||||
}
|
||||
tokio::spawn(async move {
|
||||
for chunk in bytes.chunks(61) {
|
||||
if writer.write_all(chunk).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
return Ok(Box::pin(CountingReader {
|
||||
reader,
|
||||
count: self.read_bytes.clone(),
|
||||
}));
|
||||
}
|
||||
Ok(Box::pin(FaultReader {
|
||||
bytes: self.bytes[..self.bytes.len() / 2].to_vec(),
|
||||
position: 0,
|
||||
transfer: self.transfer,
|
||||
paused: self.paused.clone(),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
struct CountingReader<R> {
|
||||
reader: R,
|
||||
count: Arc<AtomicUsize>,
|
||||
}
|
||||
impl<R: AsyncRead + Unpin> AsyncRead for CountingReader<R> {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buffer: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
let before = buffer.filled().len();
|
||||
let result = Pin::new(&mut self.reader).poll_read(cx, buffer);
|
||||
self.count
|
||||
.fetch_add(buffer.filled().len() - before, Ordering::SeqCst);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
struct FaultReader {
|
||||
bytes: Vec<u8>,
|
||||
position: usize,
|
||||
transfer: Transfer,
|
||||
paused: Arc<Semaphore>,
|
||||
}
|
||||
impl AsyncRead for FaultReader {
|
||||
fn poll_read(
|
||||
mut self: Pin<&mut Self>,
|
||||
_: &mut Context<'_>,
|
||||
buffer: &mut ReadBuf<'_>,
|
||||
) -> Poll<io::Result<()>> {
|
||||
if self.position < self.bytes.len() {
|
||||
let n = buffer.remaining().min(self.bytes.len() - self.position);
|
||||
buffer.put_slice(&self.bytes[self.position..self.position + n]);
|
||||
self.position += n;
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
match self.transfer {
|
||||
Transfer::Error => Poll::Ready(Err(io::Error::new(
|
||||
io::ErrorKind::ConnectionReset,
|
||||
"interrupted fixture",
|
||||
))),
|
||||
Transfer::Pause => {
|
||||
// Reaching this poll proves the cache consumed/wrote the prefix
|
||||
// and is awaiting further input, before any publication.
|
||||
self.paused.add_permits(1);
|
||||
Poll::Pending
|
||||
}
|
||||
Transfer::Crash => std::process::exit(73),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cache(directory: &tempfile::TempDir) -> ArtifactCache {
|
||||
ArtifactCache::new(directory.path().join("cache"), CacheLimits::default()).unwrap()
|
||||
}
|
||||
|
||||
fn final_files(cache: &ArtifactCache) -> Vec<PathBuf> {
|
||||
fs::read_dir(&cache.root)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.filter(|path| path.extension().is_some_and(|ext| ext == "artifact"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn pending_files(cache: &ArtifactCache) -> Vec<PathBuf> {
|
||||
fs::read_dir(&cache.root)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with(".pending-")
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streamed_download_and_restart_hit_return_only_verified_bytes() {
|
||||
let (artifact, descriptor) = fixture();
|
||||
let source = Source::new(&artifact, descriptor);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
let verified = source.resolve(&cache).await.unwrap();
|
||||
assert_eq!(verified.bytes(), artifact.bytes);
|
||||
assert_eq!(fs::read(&final_files(&cache)[0]).unwrap(), artifact.bytes);
|
||||
let restarted = ArtifactCache::new(&cache.root, CacheLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
source.resolve(&restarted).await.unwrap().bytes(),
|
||||
artifact.bytes
|
||||
);
|
||||
assert_eq!(source.fetches.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(source.authorizations.load(Ordering::SeqCst), 4);
|
||||
assert!(pending_files(&cache).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_bytes_do_not_bypass_owner_outage_revocation_or_denial() {
|
||||
let (artifact, descriptor) = fixture();
|
||||
let source = Source::new(&artifact, descriptor);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
source.resolve(&cache).await.unwrap();
|
||||
source.unavailable.store(1, Ordering::SeqCst);
|
||||
assert!(matches!(
|
||||
source.resolve(&cache).await,
|
||||
Err(DeliveryError::Unavailable(_))
|
||||
));
|
||||
source.unavailable.store(0, Ordering::SeqCst);
|
||||
source.deny_on.store(4, Ordering::SeqCst);
|
||||
assert!(matches!(
|
||||
source.resolve(&cache).await,
|
||||
Err(DeliveryError::Denied)
|
||||
));
|
||||
source.metadata.lock().unwrap().artifact.revoked = true;
|
||||
assert!(matches!(
|
||||
source.resolve(&cache).await,
|
||||
Err(DeliveryError::Denied)
|
||||
));
|
||||
assert_eq!(source.fetches.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn final_authorization_denies_both_download_and_cache_hit() {
|
||||
for hit in [false, true] {
|
||||
let (artifact, descriptor) = fixture();
|
||||
let source = Source::new(&artifact, descriptor);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
if hit {
|
||||
source.resolve(&cache).await.unwrap();
|
||||
}
|
||||
source
|
||||
.deny_on
|
||||
.store(if hit { 4 } else { 2 }, Ordering::SeqCst);
|
||||
assert!(matches!(
|
||||
source.resolve(&cache).await,
|
||||
Err(DeliveryError::Denied)
|
||||
));
|
||||
assert_eq!(source.fetches.load(Ordering::SeqCst), 1);
|
||||
// A verified blob can remain after denial, but is not usable authority.
|
||||
assert_eq!(final_files(&cache).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_must_bind_trusted_workspace_and_every_exact_request_field() {
|
||||
for field in [
|
||||
"workspace",
|
||||
"plan",
|
||||
"fingerprint",
|
||||
"installation",
|
||||
"artifact",
|
||||
] {
|
||||
let (artifact, descriptor) = fixture();
|
||||
let source = Source::new(&artifact, descriptor);
|
||||
let request = source.request();
|
||||
let expected = source.expectation();
|
||||
{
|
||||
let mut metadata = source.metadata.lock().unwrap();
|
||||
match field {
|
||||
"workspace" => metadata.workspace_id = "another-workspace".into(),
|
||||
"plan" => metadata.request.plan_id.push('x'),
|
||||
"fingerprint" => metadata.request.plan_fingerprint.push('x'),
|
||||
"installation" => metadata.request.installation_id.push('x'),
|
||||
"artifact" => metadata.request.artifact_digest.push('x'),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
assert!(
|
||||
matches!(
|
||||
cache
|
||||
.resolve(&source, "workspace-a", &request, &expected)
|
||||
.await,
|
||||
Err(DeliveryError::MetadataMismatch)
|
||||
),
|
||||
"{field}"
|
||||
);
|
||||
assert_eq!(source.fetches.load(Ordering::SeqCst), 0);
|
||||
assert!(final_files(&cache).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_cannot_replace_the_host_plan_descriptor() {
|
||||
let (artifact, descriptor) = fixture();
|
||||
let source = Source::new(&artifact, descriptor);
|
||||
let expected = source.expectation();
|
||||
source.metadata.lock().unwrap().artifact.version = "owner-selected-version".into();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
assert!(matches!(
|
||||
cache
|
||||
.resolve(&source, "workspace-a", &source.request(), &expected)
|
||||
.await,
|
||||
Err(DeliveryError::MetadataMismatch)
|
||||
));
|
||||
assert_eq!(source.fetches.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn canonical_package_must_match_complete_descriptor_not_only_digests() {
|
||||
for field in [
|
||||
"id",
|
||||
"name",
|
||||
"version",
|
||||
"description",
|
||||
"api",
|
||||
"world",
|
||||
"imports",
|
||||
"tools",
|
||||
"connections",
|
||||
"grants",
|
||||
"data_collections",
|
||||
"schema_identity",
|
||||
"schema_version",
|
||||
"schema_digest",
|
||||
"schema_namespace",
|
||||
"schema_dcdl",
|
||||
] {
|
||||
let (artifact, mut d) = fixture();
|
||||
match field {
|
||||
"id" => d.package_id.push('x'),
|
||||
"name" => d.name.push('x'),
|
||||
"version" => d.version.push('x'),
|
||||
"description" => d.description = Some("different".into()),
|
||||
"api" => d.schema_version += 1,
|
||||
"world" => d.world.push('x'),
|
||||
"imports" => d.required_imports.push("yoi:host/logging@2.0.0".into()),
|
||||
"tools" => d.tools[0].input_schema = serde_json::json!({"type":"string"}),
|
||||
"connections" => d.connections.push(server_api::PluginConnectionMetadata {
|
||||
name: "other".into(),
|
||||
methods: vec![],
|
||||
path_prefixes: vec![],
|
||||
}),
|
||||
"grants" => d
|
||||
.requested_grants
|
||||
.push(server_api::PluginGrantMetadata::Logging),
|
||||
"data_collections" => d
|
||||
.data_collections
|
||||
.push(server_api::PluginDataSchemaMetadata {
|
||||
collection: "notes".into(),
|
||||
schema_id: "note".into(),
|
||||
schema_version: "1".into(),
|
||||
schema: serde_json::json!({"type":"string"}),
|
||||
}),
|
||||
"schema_identity" => d.config_schema.identity.push('x'),
|
||||
"schema_version" => d.config_schema.version.push('x'),
|
||||
"schema_digest" => d.config_schema.digest.push('x'),
|
||||
"schema_namespace" => d.config_schema.namespace.push('x'),
|
||||
"schema_dcdl" => d.config_schema.dcdl = "{ different: String }".into(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
let source = Source::new(&artifact, d);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
assert!(
|
||||
matches!(
|
||||
source.resolve(&cache).await,
|
||||
Err(DeliveryError::MetadataMismatch)
|
||||
),
|
||||
"{field}"
|
||||
);
|
||||
assert!(final_files(&cache).is_empty(), "{field}");
|
||||
assert!(pending_files(&cache).is_empty(), "{field}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn corrupted_or_wrong_size_cache_file_is_refetched_and_verified() {
|
||||
for wrong_size in [false, true] {
|
||||
let (artifact, descriptor) = fixture();
|
||||
let source = Source::new(&artifact, descriptor);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
source.resolve(&cache).await.unwrap();
|
||||
let path = final_files(&cache).pop().unwrap();
|
||||
let mut corrupt = artifact.bytes.clone();
|
||||
if wrong_size {
|
||||
corrupt.truncate(7);
|
||||
} else {
|
||||
corrupt[0] ^= 1;
|
||||
}
|
||||
fs::write(&path, corrupt).unwrap();
|
||||
assert_eq!(
|
||||
source.resolve(&cache).await.unwrap().bytes(),
|
||||
artifact.bytes
|
||||
);
|
||||
assert_eq!(source.fetches.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(fs::read(path).unwrap(), artifact.bytes);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn independent_package_and_artifact_digests_are_both_required() {
|
||||
for package_digest in [false, true] {
|
||||
let (artifact, mut descriptor) = fixture();
|
||||
if package_digest {
|
||||
descriptor.package_digest = format!("sha256:{}", "0".repeat(64));
|
||||
}
|
||||
let mut source = Source::new(&artifact, descriptor);
|
||||
if !package_digest {
|
||||
source.bytes[100] ^= 1;
|
||||
}
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
assert!(matches!(
|
||||
source.resolve(&cache).await,
|
||||
Err(DeliveryError::InvalidArtifact(_))
|
||||
));
|
||||
assert!(final_files(&cache).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn declared_size_is_checked_before_fetch_and_stream_is_read_only_one_byte_past_bound() {
|
||||
let (artifact, descriptor) = fixture();
|
||||
let mut source = Source::new(&artifact, descriptor);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let mut limits = CacheLimits::default();
|
||||
limits.package.max_package_size_bytes = artifact.artifact_size - 1;
|
||||
let cache = ArtifactCache::new(directory.path().join("cache"), limits).unwrap();
|
||||
assert!(matches!(
|
||||
source.resolve(&cache).await,
|
||||
Err(DeliveryError::TooLarge)
|
||||
));
|
||||
assert_eq!(source.fetches.load(Ordering::SeqCst), 0);
|
||||
source.bytes.extend_from_slice(&vec![0; 100_000]);
|
||||
let cache = ArtifactCache::new(&cache.root, CacheLimits::default()).unwrap();
|
||||
assert!(matches!(
|
||||
source.resolve(&cache).await,
|
||||
Err(DeliveryError::TooLarge)
|
||||
));
|
||||
assert_eq!(
|
||||
source.read_bytes.load(Ordering::SeqCst) as u64,
|
||||
artifact.artifact_size + 1
|
||||
);
|
||||
assert!(final_files(&cache).is_empty());
|
||||
assert!(pending_files(&cache).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_and_failed_streams_never_publish_and_restart_can_retry() {
|
||||
for transfer in [Transfer::Short, Transfer::Error] {
|
||||
let (artifact, descriptor) = fixture();
|
||||
let mut source = Source::new(&artifact, descriptor);
|
||||
source.transfer = transfer;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
let error = source.resolve(&cache).await.unwrap_err();
|
||||
assert!(matches!(
|
||||
error,
|
||||
DeliveryError::InvalidArtifact(_) | DeliveryError::Io(_)
|
||||
));
|
||||
assert!(final_files(&cache).is_empty());
|
||||
assert!(pending_files(&cache).is_empty());
|
||||
source.transfer = Transfer::Complete;
|
||||
let restarted = ArtifactCache::new(&cache.root, CacheLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
source.resolve(&restarted).await.unwrap().bytes(),
|
||||
artifact.bytes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_drops_staging_without_exposing_a_partial_final_file() {
|
||||
let (artifact, descriptor) = fixture();
|
||||
let mut source = Source::new(&artifact, descriptor);
|
||||
source.transfer = Transfer::Pause;
|
||||
let source = Arc::new(source);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = Arc::new(cache(&directory));
|
||||
let task = tokio::spawn({
|
||||
let source = source.clone();
|
||||
let cache = cache.clone();
|
||||
async move { source.resolve(&cache).await }
|
||||
});
|
||||
tokio::time::timeout(Duration::from_secs(5), source.paused.acquire())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.forget();
|
||||
assert_eq!(pending_files(&cache).len(), 1);
|
||||
assert!(final_files(&cache).is_empty());
|
||||
task.abort();
|
||||
assert!(task.await.unwrap_err().is_cancelled());
|
||||
assert!(pending_files(&cache).is_empty());
|
||||
let retry = Source::new(&artifact, source.expectation().artifact);
|
||||
assert_eq!(retry.resolve(&cache).await.unwrap().bytes(), artifact.bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stalled_transfer_deadline_releases_lock_and_staging() {
|
||||
let (artifact, descriptor) = fixture();
|
||||
let mut source = Source::new(&artifact, descriptor);
|
||||
source.transfer = Transfer::Pause;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let limits = CacheLimits {
|
||||
delivery_timeout: Duration::from_millis(100),
|
||||
..CacheLimits::default()
|
||||
};
|
||||
let cache = ArtifactCache::new(directory.path().join("cache"), limits).unwrap();
|
||||
assert!(matches!(
|
||||
source.resolve(&cache).await,
|
||||
Err(DeliveryError::Timeout)
|
||||
));
|
||||
assert!(pending_files(&cache).is_empty());
|
||||
assert!(final_files(&cache).is_empty());
|
||||
source.transfer = Transfer::Complete;
|
||||
let restarted = ArtifactCache::new(&cache.root, CacheLimits::default()).unwrap();
|
||||
source.resolve(&restarted).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn concurrent_instances_converge_on_one_verified_publication() {
|
||||
let (artifact, descriptor) = fixture();
|
||||
let source = Arc::new(Source::new(&artifact, descriptor));
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let first = cache(&directory);
|
||||
let mut tasks = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let cache = ArtifactCache::new(&first.root, CacheLimits::default()).unwrap();
|
||||
let source = source.clone();
|
||||
tasks.push(tokio::spawn(async move { source.resolve(&cache).await }));
|
||||
}
|
||||
for task in tasks {
|
||||
assert_eq!(task.await.unwrap().unwrap().bytes(), artifact.bytes);
|
||||
}
|
||||
assert_eq!(source.fetches.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(source.authorizations.load(Ordering::SeqCst), 16);
|
||||
assert_eq!(final_files(&first).len(), 1);
|
||||
assert!(pending_files(&first).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_discards_stale_partial_even_when_it_contains_a_complete_valid_artifact() {
|
||||
let (artifact, descriptor) = fixture();
|
||||
let source = Source::new(&artifact, descriptor);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
fs::write(
|
||||
cache.root.join(".pending-crashed-complete"),
|
||||
&artifact.bytes,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
cache.root.join(".pending-crashed-prefix"),
|
||||
&artifact.bytes[..99],
|
||||
)
|
||||
.unwrap();
|
||||
let restarted = ArtifactCache::new(&cache.root, CacheLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
source.resolve(&restarted).await.unwrap().bytes(),
|
||||
artifact.bytes
|
||||
);
|
||||
assert_eq!(source.fetches.load(Ordering::SeqCst), 1);
|
||||
assert!(pending_files(&cache).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn eviction_bounds_bytes_and_entry_count_independently_without_invalidating_returned_bytes() {
|
||||
let (artifact, descriptor) = fixture();
|
||||
for (max_cache_bytes, max_entries) in
|
||||
[(artifact.artifact_size, 8), (artifact.artifact_size * 8, 1)]
|
||||
{
|
||||
let source = Source::new(&artifact, descriptor.clone());
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let limits = CacheLimits {
|
||||
max_cache_bytes,
|
||||
max_entries,
|
||||
..CacheLimits::default()
|
||||
};
|
||||
let cache = ArtifactCache::new(directory.path().join("cache"), limits).unwrap();
|
||||
let held = source.resolve(&cache).await.unwrap();
|
||||
let mut other = Source::new(&artifact, descriptor.clone());
|
||||
other.authority = "server-b".into();
|
||||
other.resolve(&cache).await.unwrap();
|
||||
assert_eq!(final_files(&cache).len(), 1);
|
||||
assert_eq!(
|
||||
fs::metadata(&final_files(&cache)[0]).unwrap().len(),
|
||||
artifact.artifact_size
|
||||
);
|
||||
assert_eq!(held.bytes(), artifact.bytes);
|
||||
source.resolve(&cache).await.unwrap();
|
||||
assert_eq!(source.fetches.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(final_files(&cache).len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hashed_authority_separates_servers_while_fresh_workspace_and_worker_owners_are_required() {
|
||||
let (artifact, descriptor) = fixture();
|
||||
let mut source = Source::new(&artifact, descriptor.clone());
|
||||
source.authority = "../../outside/opaque:server".into();
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
source.resolve(&cache).await.unwrap();
|
||||
let mut other = Source::new(&artifact, descriptor.clone());
|
||||
other.authority = "server-b".into();
|
||||
other.resolve(&cache).await.unwrap();
|
||||
assert_eq!(final_files(&cache).len(), 2);
|
||||
for path in final_files(&cache) {
|
||||
assert_eq!(path.parent().unwrap(), cache.root);
|
||||
assert!(is_cache_filename(
|
||||
path.file_name().unwrap().to_str().unwrap()
|
||||
));
|
||||
assert!(
|
||||
!path
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.contains("outside")
|
||||
);
|
||||
}
|
||||
let mut workspace_b = Source::new(&artifact, descriptor.clone());
|
||||
workspace_b.authority = source.authority.clone();
|
||||
workspace_b.metadata.lock().unwrap().workspace_id = "workspace-b".into();
|
||||
let request = workspace_b.request();
|
||||
let expected = workspace_b.expectation();
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(&workspace_b, "workspace-b", &request, &expected)
|
||||
.await
|
||||
.unwrap()
|
||||
.bytes(),
|
||||
artifact.bytes
|
||||
);
|
||||
assert_eq!(workspace_b.fetches.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(workspace_b.authorizations.load(Ordering::SeqCst), 2);
|
||||
let mut different_worker_owner = Source::new(&artifact, descriptor);
|
||||
different_worker_owner.authority = source.authority;
|
||||
different_worker_owner.deny_on.store(1, Ordering::SeqCst);
|
||||
assert!(matches!(
|
||||
different_worker_owner.resolve(&cache).await,
|
||||
Err(DeliveryError::Denied)
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn private_root_and_non_symlink_cache_entries_are_enforced() {
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let public = directory.path().join("public");
|
||||
fs::create_dir(&public).unwrap();
|
||||
fs::set_permissions(&public, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(matches!(
|
||||
ArtifactCache::new(&public, CacheLimits::default()),
|
||||
Err(DeliveryError::InvalidCacheRoot)
|
||||
));
|
||||
let link = directory.path().join("link");
|
||||
symlink(&public, &link).unwrap();
|
||||
assert!(matches!(
|
||||
ArtifactCache::new(&link, CacheLimits::default()),
|
||||
Err(DeliveryError::InvalidCacheRoot)
|
||||
));
|
||||
let cache = cache(&directory);
|
||||
assert_eq!(
|
||||
fs::metadata(&cache.root).unwrap().permissions().mode() & 0o777,
|
||||
0o700
|
||||
);
|
||||
let sentinel = directory.path().join("sentinel");
|
||||
fs::write(&sentinel, b"do not change").unwrap();
|
||||
symlink(&sentinel, cache.root.join(".pending-symlink")).unwrap();
|
||||
let (artifact, descriptor) = fixture();
|
||||
let source = Source::new(&artifact, descriptor);
|
||||
assert!(matches!(
|
||||
source.resolve(&cache).await,
|
||||
Err(DeliveryError::InvalidCacheRoot)
|
||||
));
|
||||
assert_eq!(fs::read(sentinel).unwrap(), b"do not change");
|
||||
}
|
||||
|
||||
// Deliberately corrupt the shared package's API without breaking ZIP integrity,
|
||||
// so rejection must come from the common manifest verifier, not a bad checksum.
|
||||
fn unsupported_api_bytes(bytes: &mut [u8]) {
|
||||
let header = bytes
|
||||
.windows(4)
|
||||
.position(|window| window == b"PK\x03\x04")
|
||||
.unwrap();
|
||||
let mut offset = header;
|
||||
let mut crc = None;
|
||||
while &bytes[offset..offset + 4] == b"PK\x03\x04" {
|
||||
let size = u32::from_le_bytes(bytes[offset + 18..offset + 22].try_into().unwrap()) as usize;
|
||||
let name_size =
|
||||
u16::from_le_bytes(bytes[offset + 26..offset + 28].try_into().unwrap()) as usize;
|
||||
let extra =
|
||||
u16::from_le_bytes(bytes[offset + 28..offset + 30].try_into().unwrap()) as usize;
|
||||
let start = offset + 30 + name_size + extra;
|
||||
if &bytes[offset + 30..offset + 30 + name_size] == b"plugin.toml" {
|
||||
let payload = &mut bytes[start..start + size];
|
||||
let index = payload
|
||||
.windows(b"schema_version = 2".len())
|
||||
.position(|window| window == b"schema_version = 2")
|
||||
.unwrap();
|
||||
payload[index + b"schema_version = ".len()] = b'9';
|
||||
let mut checksum = !0_u32;
|
||||
for byte in payload {
|
||||
checksum ^= u32::from(*byte);
|
||||
for _ in 0..8 {
|
||||
checksum = (checksum >> 1) ^ (0xedb88320 & 0_u32.wrapping_sub(checksum & 1));
|
||||
}
|
||||
}
|
||||
let checksum = (!checksum).to_le_bytes();
|
||||
bytes[offset + 14..offset + 18].copy_from_slice(&checksum);
|
||||
crc = Some(checksum);
|
||||
}
|
||||
offset = start + size;
|
||||
}
|
||||
while &bytes[offset..offset + 4] == b"PK\x01\x02" {
|
||||
let name_size =
|
||||
u16::from_le_bytes(bytes[offset + 28..offset + 30].try_into().unwrap()) as usize;
|
||||
let extra =
|
||||
u16::from_le_bytes(bytes[offset + 30..offset + 32].try_into().unwrap()) as usize;
|
||||
let comment =
|
||||
u16::from_le_bytes(bytes[offset + 32..offset + 34].try_into().unwrap()) as usize;
|
||||
if &bytes[offset + 46..offset + 46 + name_size] == b"plugin.toml" {
|
||||
bytes[offset + 16..offset + 20].copy_from_slice(&crc.unwrap());
|
||||
}
|
||||
offset += 46 + name_size + extra + comment;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_api_is_rejected_by_common_verifier_before_publication() {
|
||||
let (mut artifact, mut descriptor) = fixture();
|
||||
unsupported_api_bytes(&mut artifact.bytes);
|
||||
artifact.artifact_digest = format!("sha256:{}", hash(&artifact.bytes));
|
||||
descriptor.artifact_digest = artifact.artifact_digest.clone();
|
||||
descriptor.schema_version = 9;
|
||||
let source = Source::new(&artifact, descriptor);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
let error = source.resolve(&cache).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, DeliveryError::InvalidArtifact(ref message) if message.contains("unsupported schema")),
|
||||
"{error:?}"
|
||||
);
|
||||
assert!(final_files(&cache).is_empty());
|
||||
}
|
||||
|
||||
struct Actor {
|
||||
child: Child,
|
||||
lines: std::sync::mpsc::Receiver<Option<String>>,
|
||||
stdout_thread: Option<std::thread::JoinHandle<()>>,
|
||||
stderr_thread: Option<std::thread::JoinHandle<String>>,
|
||||
text: String,
|
||||
}
|
||||
impl Drop for Actor {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
if let Some(thread) = self.stdout_thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
if let Some(thread) = self.stderr_thread.take() {
|
||||
let _ = thread.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Actor {
|
||||
fn spawn(root: &Path, action: &str) -> Self {
|
||||
let mut child = Command::new(std::env::current_exe().unwrap())
|
||||
.args([
|
||||
"--exact",
|
||||
"delivery::tests::process_cache_actor",
|
||||
"--ignored",
|
||||
"--nocapture",
|
||||
"--test-threads=1",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
serde_json::to_writer(child.stdin.take().unwrap(), &(root, action)).unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut stderr = child.stderr.take().unwrap();
|
||||
let (sender, lines) = std::sync::mpsc::channel();
|
||||
let stdout_thread = std::thread::spawn(move || {
|
||||
for line in BufReader::new(stdout).lines() {
|
||||
if sender.send(Some(line.unwrap())).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
let _ = sender.send(None);
|
||||
});
|
||||
let stderr_thread = std::thread::spawn(move || {
|
||||
let mut text = String::new();
|
||||
stderr.read_to_string(&mut text).unwrap();
|
||||
text
|
||||
});
|
||||
Self {
|
||||
child,
|
||||
lines,
|
||||
stdout_thread: Some(stdout_thread),
|
||||
stderr_thread: Some(stderr_thread),
|
||||
text: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn line(&mut self) -> Option<String> {
|
||||
let line = self
|
||||
.lines
|
||||
.recv_timeout(Duration::from_secs(10))
|
||||
.unwrap_or_else(|error| panic!("child output deadline: {error}: {}", self.text));
|
||||
if let Some(line) = &line {
|
||||
self.text.push_str(line);
|
||||
self.text.push('\n');
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
fn ready(&mut self) {
|
||||
loop {
|
||||
let line = self.line().expect("child exited before readiness");
|
||||
if line.contains("cache-actor-ready") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> (std::process::ExitStatus, String) {
|
||||
while self.line().is_some() {}
|
||||
let status = self.child.wait().unwrap();
|
||||
self.text
|
||||
.push_str(&self.stderr_thread.take().unwrap().join().unwrap());
|
||||
(status, std::mem::take(&mut self.text))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "subprocess actor invoked by ordinary cache integration tests"]
|
||||
fn process_cache_actor() {
|
||||
let (root, action): (PathBuf, String) = serde_json::from_reader(io::stdin()).unwrap();
|
||||
let (artifact, descriptor) = fixture();
|
||||
let mut source = Source::new(&artifact, descriptor);
|
||||
if action == "crash" {
|
||||
source.transfer = Transfer::Crash;
|
||||
}
|
||||
let cache = ArtifactCache::new(root, CacheLimits::default()).unwrap();
|
||||
if action == "resolve" {
|
||||
let probe = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(cache.root.join(".cache.lock"))
|
||||
.unwrap();
|
||||
assert!(!fs4::fs_std::FileExt::try_lock_exclusive(&probe).unwrap());
|
||||
}
|
||||
println!("cache-actor-ready");
|
||||
io::stdout().flush().unwrap();
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let verified = runtime.block_on(source.resolve(&cache)).unwrap();
|
||||
assert_eq!(verified.bytes(), artifact.bytes);
|
||||
println!(
|
||||
"cache-actor-fetches={}",
|
||||
source.fetches.load(Ordering::SeqCst)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn independent_processes_converge_after_contending_for_same_cache_lock() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
let lock = lock_cache(cache.root.join(".cache.lock")).await.unwrap();
|
||||
let mut first = Actor::spawn(&cache.root, "resolve");
|
||||
let mut second = Actor::spawn(&cache.root, "resolve");
|
||||
first.ready();
|
||||
second.ready();
|
||||
drop(lock);
|
||||
let (a, a_text) = first.finish();
|
||||
let (b, b_text) = second.finish();
|
||||
assert!(a.success(), "{a_text}");
|
||||
assert!(b.success(), "{b_text}");
|
||||
assert_eq!(
|
||||
format!("{a_text}{b_text}")
|
||||
.matches("cache-actor-fetches=1")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
format!("{a_text}{b_text}")
|
||||
.matches("cache-actor-fetches=0")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(final_files(&cache).len(), 1);
|
||||
assert!(pending_files(&cache).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_exit_before_publish_leaves_no_final_bytes_and_restart_discards_partial() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = cache(&directory);
|
||||
let mut actor = Actor::spawn(&cache.root, "crash");
|
||||
actor.ready();
|
||||
let (status, text) = actor.finish();
|
||||
assert_eq!(status.code(), Some(73), "{text}");
|
||||
assert!(final_files(&cache).is_empty());
|
||||
assert_eq!(pending_files(&cache).len(), 1);
|
||||
let (artifact, descriptor) = fixture();
|
||||
let source = Source::new(&artifact, descriptor);
|
||||
let restarted = ArtifactCache::new(&cache.root, CacheLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
source.resolve(&restarted).await.unwrap().bytes(),
|
||||
artifact.bytes
|
||||
);
|
||||
assert!(pending_files(&cache).is_empty());
|
||||
assert_eq!(final_files(&cache).len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
mod service;
|
||||
pub use service::{CompiledService, ServiceInvocationResult, ServiceOutput};
|
||||
mod ticket;
|
||||
use crate::sandbox::SandboxLimits;
|
||||
use crate::{
|
||||
bindings,
|
||||
host::{self, Connection, Host, InvocationContext, Operation, errors, http, logging},
|
||||
};
|
||||
use manifest::plugin::{self, PluginPackageLimits, PluginPackageManifest, PluginToolManifest};
|
||||
use serde::Serialize;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use tokio::time::Instant;
|
||||
use wasmtime::component::{Component, Linker, Resource, ResourceTable};
|
||||
use wasmtime::{Config, Engine, Store};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Limits {
|
||||
pub payload_bytes: usize,
|
||||
pub config_bytes: usize,
|
||||
pub memory_bytes: usize,
|
||||
pub table_elements: usize,
|
||||
pub instances: usize,
|
||||
pub memories: usize,
|
||||
pub tables: usize,
|
||||
pub resources: usize,
|
||||
pub fuel: u64,
|
||||
pub wall_time: Duration,
|
||||
pub host_operations: usize,
|
||||
pub http_body_bytes: usize,
|
||||
pub http_header_bytes: usize,
|
||||
pub http_header_pairs: usize,
|
||||
pub log_bytes: usize,
|
||||
}
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
payload_bytes: 64 * 1024,
|
||||
config_bytes: 64 * 1024,
|
||||
memory_bytes: 256 * 1024 * 1024,
|
||||
table_elements: 10_000,
|
||||
instances: 32,
|
||||
memories: 4,
|
||||
tables: 8,
|
||||
resources: 32,
|
||||
fuel: 10_000_000,
|
||||
wall_time: Duration::from_secs(30),
|
||||
host_operations: 32,
|
||||
http_body_bytes: 1024 * 1024,
|
||||
http_header_bytes: 16 * 1024,
|
||||
http_header_pairs: 64,
|
||||
log_bytes: 16 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ExecutionError {
|
||||
#[error("invalid artifact: {0}")]
|
||||
Artifact(String),
|
||||
#[error("unsupported Component execution policy: {0}")]
|
||||
UnsupportedComponent(String),
|
||||
#[error("invalid invocation: {0}")]
|
||||
InvalidInput(String),
|
||||
#[error("invalid output: {0}")]
|
||||
InvalidOutput(String),
|
||||
#[error("invocation cancelled")]
|
||||
Cancelled,
|
||||
#[error("invocation deadline exceeded")]
|
||||
Deadline,
|
||||
#[error("Host denied or failed operation: {0:?}")]
|
||||
Host(errors::HostError),
|
||||
#[error("guest error {code}: {message}")]
|
||||
Guest { code: String, message: String },
|
||||
#[error("Component trap or resource limit: {0}")]
|
||||
Trap(String),
|
||||
}
|
||||
|
||||
/// Content identity only; both digests are independently verified by the package owner.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ArtifactIdentity {
|
||||
pub package_digest: String,
|
||||
pub artifact_digest: String,
|
||||
pub artifact_size: u64,
|
||||
}
|
||||
|
||||
/// Immutable compiled bytes and declarations, not execution authority.
|
||||
/// Private fields prevent mutable package metadata from bypassing verification.
|
||||
pub struct CompiledTool {
|
||||
component: Component,
|
||||
manifest: PluginPackageManifest,
|
||||
identity: ArtifactIdentity,
|
||||
}
|
||||
impl CompiledTool {
|
||||
pub fn identity(&self) -> &ArtifactIdentity {
|
||||
&self.identity
|
||||
}
|
||||
|
||||
/// Declarations from the verified immutable package, not execution grants.
|
||||
pub fn tools(&self) -> &[PluginToolManifest] {
|
||||
&self.manifest.tools
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InvocationResult {
|
||||
pub output: bindings::exports::yoi::plugin::tool::Output,
|
||||
pub dropped_logs: u64,
|
||||
}
|
||||
|
||||
pub struct Executor {
|
||||
engine: Engine,
|
||||
limits: Limits,
|
||||
}
|
||||
impl Executor {
|
||||
/// Limits are fixed by the Host, never selected by guest input.
|
||||
pub fn new(limits: Limits) -> wasmtime::Result<Self> {
|
||||
let ceiling = Limits::default();
|
||||
macro_rules! check_ceiling {
|
||||
($($field:ident),+ $(,)?) => { $(
|
||||
if limits.$field > ceiling.$field {
|
||||
return Err(wasmtime::Error::msg(concat!("limit exceeds supported ceiling: ", stringify!($field))));
|
||||
}
|
||||
)+ };
|
||||
}
|
||||
check_ceiling!(
|
||||
payload_bytes,
|
||||
config_bytes,
|
||||
memory_bytes,
|
||||
table_elements,
|
||||
instances,
|
||||
memories,
|
||||
tables,
|
||||
resources,
|
||||
fuel,
|
||||
wall_time,
|
||||
host_operations,
|
||||
http_body_bytes,
|
||||
http_header_bytes,
|
||||
http_header_pairs,
|
||||
log_bytes
|
||||
);
|
||||
let mut config = Config::new();
|
||||
config
|
||||
.async_support(true)
|
||||
.wasm_component_model(true)
|
||||
.consume_fuel(true)
|
||||
.epoch_interruption(true)
|
||||
.wasm_threads(false)
|
||||
.wasm_shared_everything_threads(false);
|
||||
Ok(Self {
|
||||
engine: Engine::new(&config)?,
|
||||
limits,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn limits(&self) -> &Limits {
|
||||
&self.limits
|
||||
}
|
||||
|
||||
/// Reuses the canonical package/digest/ABI validator; no path discovery or
|
||||
/// duplicate pack/hash implementation is involved. Compilation executes no guest.
|
||||
pub fn compile(
|
||||
&self,
|
||||
artifact: &[u8],
|
||||
package_digest: &str,
|
||||
artifact_digest: &str,
|
||||
artifact_size: u64,
|
||||
) -> Result<CompiledTool, ExecutionError> {
|
||||
let verified = plugin::verify_plugin_artifact(
|
||||
artifact,
|
||||
package_digest,
|
||||
artifact_digest,
|
||||
artifact_size,
|
||||
&PluginPackageLimits::default(),
|
||||
)
|
||||
.map_err(|e| ExecutionError::Artifact(e.message))?;
|
||||
let manifest = verified.package.manifest;
|
||||
let component_bytes = &verified.files[&manifest.runtime.component];
|
||||
crate::sandbox::check_component_resources(component_bytes)
|
||||
.map_err(|e| ExecutionError::UnsupportedComponent(e.to_string()))?;
|
||||
let component = Component::new(&self.engine, component_bytes)
|
||||
.map_err(|e| ExecutionError::Artifact(e.to_string()))?;
|
||||
Ok(CompiledTool {
|
||||
component,
|
||||
manifest,
|
||||
identity: ArtifactIdentity {
|
||||
package_digest: package_digest.into(),
|
||||
artifact_digest: artifact_digest.into(),
|
||||
artifact_size,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// All computation and Host waits share the earlier of the caller deadline
|
||||
/// and local ceiling. Dropping this future also drops its Store and resources.
|
||||
pub async fn invoke(
|
||||
&self,
|
||||
compiled: &CompiledTool,
|
||||
mut context: InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
tool_name: &str,
|
||||
input_json: &str,
|
||||
config_json: &str,
|
||||
) -> Result<InvocationResult, ExecutionError> {
|
||||
context.deadline = context.deadline.min(Instant::now() + self.limits.wall_time);
|
||||
context.cancellation = context.cancellation.child_token();
|
||||
let _lifetime = InvocationLifetime(context.cancellation.clone());
|
||||
let deadline = context.deadline;
|
||||
let cancellation = context.cancellation.clone();
|
||||
let execution = self.run(
|
||||
compiled,
|
||||
context,
|
||||
provider,
|
||||
tool_name,
|
||||
input_json,
|
||||
config_json,
|
||||
);
|
||||
tokio::pin!(execution);
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(10));
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = cancellation.cancelled() => return Err(ExecutionError::Cancelled),
|
||||
_ = tokio::time::sleep_until(deadline) => return Err(ExecutionError::Deadline),
|
||||
result = &mut execution => {
|
||||
if cancellation.is_cancelled() { return Err(ExecutionError::Cancelled); }
|
||||
if Instant::now() >= deadline { return Err(ExecutionError::Deadline); }
|
||||
return result;
|
||||
},
|
||||
_ = tick.tick() => self.engine.increment_epoch(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(
|
||||
&self,
|
||||
compiled: &CompiledTool,
|
||||
context: InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
tool_name: &str,
|
||||
input_json: &str,
|
||||
config_json: &str,
|
||||
) -> Result<InvocationResult, ExecutionError> {
|
||||
if !Engine::same(&self.engine, compiled.component.engine()) {
|
||||
return Err(ExecutionError::Artifact(
|
||||
"Component belongs to another executor".into(),
|
||||
));
|
||||
}
|
||||
if context.artifact != compiled.identity || context.tool_name != tool_name {
|
||||
return Err(ExecutionError::InvalidInput(
|
||||
"artifact/Tool does not match Host-bound context".into(),
|
||||
));
|
||||
}
|
||||
let tool = compiled
|
||||
.manifest
|
||||
.tools
|
||||
.iter()
|
||||
.find(|t| t.name == tool_name)
|
||||
.ok_or_else(|| ExecutionError::InvalidInput("undeclared Tool".into()))?;
|
||||
if input_json.len() > self.limits.payload_bytes
|
||||
|| config_json.len() > self.limits.config_bytes
|
||||
|| context.invocation_id.len() > 1024
|
||||
{
|
||||
return Err(ExecutionError::InvalidInput(
|
||||
"input/config/identity exceeds limit".into(),
|
||||
));
|
||||
}
|
||||
validate_json(&tool.input_schema, input_json).map_err(ExecutionError::InvalidInput)?;
|
||||
serde_json::from_str::<serde_json::Value>(config_json)
|
||||
.map_err(|e| ExecutionError::InvalidInput(e.to_string()))?;
|
||||
provider
|
||||
.authorize(&context, Operation::Admit)
|
||||
.await
|
||||
.map_err(ExecutionError::Host)?;
|
||||
check_current(&context).map_err(ExecutionError::Host)?;
|
||||
let limits = &self.limits;
|
||||
let state = State {
|
||||
context: None,
|
||||
provider: provider.clone(),
|
||||
limits: limits.clone(),
|
||||
manifest: compiled.manifest.clone(),
|
||||
table: ResourceTable::new(),
|
||||
operations: 0,
|
||||
resources: 0,
|
||||
log_bytes: 0,
|
||||
dropped_logs: 0,
|
||||
store_limits: SandboxLimits::new(limits),
|
||||
logging_only: false,
|
||||
effects: Arc::new(service::Effects::default()),
|
||||
};
|
||||
let mut store = Store::new(&self.engine, state);
|
||||
store.limiter(|state| &mut state.store_limits);
|
||||
store.set_fuel(limits.fuel).map_err(trap)?;
|
||||
store
|
||||
.fuel_async_yield_interval(Some(10_000))
|
||||
.map_err(trap)?;
|
||||
store.set_epoch_deadline(1);
|
||||
store.epoch_deadline_async_yield_and_update(1);
|
||||
let mut linker = Linker::new(&self.engine);
|
||||
bindings::HttpsLoggedToolPlugin::add_to_linker::<_, wasmtime::component::HasSelf<_>>(
|
||||
&mut linker,
|
||||
|s| s,
|
||||
)
|
||||
.map_err(trap)?;
|
||||
if compiled.manifest.runtime.world == plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD {
|
||||
use crate::host::{extension_data, objects, ticket_read};
|
||||
objects::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s)
|
||||
.map_err(trap)?;
|
||||
ticket_read::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s)
|
||||
.map_err(trap)?;
|
||||
extension_data::add_to_linker::<_, wasmtime::component::HasSelf<_>>(&mut linker, |s| s)
|
||||
.map_err(trap)?;
|
||||
}
|
||||
let instance = bindings::HttpsLoggedToolPlugin::instantiate_async(
|
||||
&mut store,
|
||||
&compiled.component,
|
||||
&linker,
|
||||
)
|
||||
.await
|
||||
.map_err(trap)?;
|
||||
// No import during instantiate/start may act, including logging. Service
|
||||
// on-start is a different world and has no adapter here.
|
||||
check_current(&context).map_err(ExecutionError::Host)?;
|
||||
store.data_mut().context = Some(context.clone());
|
||||
let invocation = bindings::exports::yoi::plugin::tool::Invocation {
|
||||
invocation_id: context.invocation_id.clone(),
|
||||
tool_name: tool_name.into(),
|
||||
input_json: input_json.into(),
|
||||
config_json: config_json.into(),
|
||||
remaining_ms: context
|
||||
.deadline
|
||||
.saturating_duration_since(Instant::now())
|
||||
.as_millis()
|
||||
.min(u32::MAX as u128) as u32,
|
||||
};
|
||||
let result = instance
|
||||
.yoi_plugin_tool()
|
||||
.call_invoke(&mut store, &invocation)
|
||||
.await
|
||||
.map_err(trap)?;
|
||||
check_current(&context).map_err(ExecutionError::Host)?;
|
||||
provider
|
||||
.authorize(&context, Operation::Publish)
|
||||
.await
|
||||
.map_err(ExecutionError::Host)?;
|
||||
check_current(&context).map_err(ExecutionError::Host)?;
|
||||
let output = match result {
|
||||
Ok(output) => {
|
||||
validate_output(tool, &output, limits.payload_bytes)?;
|
||||
output
|
||||
}
|
||||
Err(error) => {
|
||||
if error.message.len().saturating_add(error.code.len()) > limits.payload_bytes {
|
||||
return Err(ExecutionError::InvalidOutput(
|
||||
"guest error exceeds limit".into(),
|
||||
));
|
||||
}
|
||||
let code = if tool.error_codes.contains(&error.code) {
|
||||
error.code
|
||||
} else {
|
||||
"plugin-failed".into()
|
||||
};
|
||||
#[derive(Serialize)]
|
||||
struct Failure<'a> {
|
||||
code: &'a str,
|
||||
message: &'a str,
|
||||
}
|
||||
if serde_json::to_vec(&Failure {
|
||||
code: &code,
|
||||
message: &error.message,
|
||||
})
|
||||
.unwrap()
|
||||
.len()
|
||||
> limits.payload_bytes
|
||||
{
|
||||
return Err(ExecutionError::InvalidOutput(
|
||||
"serialized guest error exceeds limit".into(),
|
||||
));
|
||||
}
|
||||
return Err(ExecutionError::Guest {
|
||||
code,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
Ok(InvocationResult {
|
||||
output,
|
||||
dropped_logs: store.data().dropped_logs,
|
||||
})
|
||||
}
|
||||
}
|
||||
fn trap(e: wasmtime::Error) -> ExecutionError {
|
||||
ExecutionError::Trap(format!("{e:#}"))
|
||||
}
|
||||
fn validate_json(schema: &serde_json::Value, json: &str) -> Result<(), String> {
|
||||
let value: serde_json::Value = serde_json::from_str(json).map_err(|e| e.to_string())?;
|
||||
// Schema admission already forbids external refs/dialects in manifest::plugin.
|
||||
let validator = jsonschema::options()
|
||||
.with_draft(jsonschema::Draft::Draft202012)
|
||||
.should_validate_formats(true)
|
||||
.build(schema)
|
||||
.map_err(|e| e.to_string())?;
|
||||
validator.validate(&value).map_err(|e| e.to_string())
|
||||
}
|
||||
fn validate_output(
|
||||
tool: &PluginToolManifest,
|
||||
output: &bindings::exports::yoi::plugin::tool::Output,
|
||||
max: usize,
|
||||
) -> Result<(), ExecutionError> {
|
||||
let raw_bytes = output
|
||||
.summary
|
||||
.len()
|
||||
.saturating_add(output.content.as_ref().map_or(0, String::len))
|
||||
.saturating_add(output.structured_json.as_ref().map_or(0, String::len));
|
||||
if raw_bytes > max {
|
||||
return Err(ExecutionError::InvalidOutput("output exceeds limit".into()));
|
||||
}
|
||||
// Use the complete serialized envelope budget, not separate per-field limits.
|
||||
#[derive(Serialize)]
|
||||
struct Envelope<'a> {
|
||||
summary: &'a str,
|
||||
content: &'a Option<String>,
|
||||
structured_json: &'a Option<String>,
|
||||
}
|
||||
let bytes = serde_json::to_vec(&Envelope {
|
||||
summary: &output.summary,
|
||||
content: &output.content,
|
||||
structured_json: &output.structured_json,
|
||||
})
|
||||
.unwrap();
|
||||
if bytes.len() > max {
|
||||
return Err(ExecutionError::InvalidOutput("output exceeds limit".into()));
|
||||
}
|
||||
let json = output
|
||||
.structured_json
|
||||
.as_deref()
|
||||
.ok_or_else(|| ExecutionError::InvalidOutput("missing structured output".into()))?;
|
||||
validate_json(&tool.output_schema, json).map_err(ExecutionError::InvalidOutput)
|
||||
}
|
||||
|
||||
struct InvocationLifetime(host::CancellationToken);
|
||||
impl Drop for InvocationLifetime {
|
||||
fn drop(&mut self) {
|
||||
self.0.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
struct State {
|
||||
context: Option<InvocationContext>,
|
||||
provider: Arc<dyn Host>,
|
||||
limits: Limits,
|
||||
manifest: PluginPackageManifest,
|
||||
table: ResourceTable,
|
||||
operations: usize,
|
||||
resources: usize,
|
||||
log_bytes: usize,
|
||||
dropped_logs: u64,
|
||||
store_limits: SandboxLimits,
|
||||
logging_only: bool,
|
||||
effects: Arc<service::Effects>,
|
||||
}
|
||||
fn check_current(context: &InvocationContext) -> Result<(), errors::HostError> {
|
||||
if context.cancellation.is_cancelled() {
|
||||
return Err(host::error(errors::Code::Cancelled, "invocation cancelled"));
|
||||
}
|
||||
if Instant::now() >= context.deadline {
|
||||
return Err(host::error(
|
||||
errors::Code::DeadlineExceeded,
|
||||
"invocation deadline exceeded",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
impl State {
|
||||
fn current(&self) -> wasmtime::Result<InvocationContext> {
|
||||
let context = self
|
||||
.context
|
||||
.as_ref()
|
||||
.ok_or_else(|| wasmtime::Error::msg("Host import outside export context"))?;
|
||||
check_current(context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
|
||||
Ok(context.clone())
|
||||
}
|
||||
fn protected_current(&self) -> wasmtime::Result<InvocationContext> {
|
||||
let context = self.current()?;
|
||||
if self.logging_only {
|
||||
return Err(wasmtime::Error::msg(
|
||||
"Service lifecycle permits logging only",
|
||||
));
|
||||
}
|
||||
Ok(context)
|
||||
}
|
||||
fn operation(&mut self) -> Result<(), errors::HostError> {
|
||||
if self.operations >= self.limits.host_operations {
|
||||
return Err(host::error(
|
||||
errors::Code::QuotaExceeded,
|
||||
"Host operation limit",
|
||||
));
|
||||
}
|
||||
self.operations += 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl bindings::yoi::host::errors::Host for State {}
|
||||
impl bindings::yoi::host::connections::HostConnection for State {
|
||||
async fn drop(&mut self, resource: Resource<Connection>) -> wasmtime::Result<()> {
|
||||
self.table.delete(resource)?;
|
||||
self.resources -= 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl bindings::yoi::host::connections::Host for State {
|
||||
async fn open(
|
||||
&mut self,
|
||||
slot: String,
|
||||
) -> wasmtime::Result<Result<Resource<Connection>, errors::HostError>> {
|
||||
let context = self.protected_current()?;
|
||||
if let Err(e) = self.operation() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let declared = self.manifest.connections.iter().any(|s| s.name == slot);
|
||||
if !declared || !context.grants.connection_slots.contains(&slot) {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::Denied,
|
||||
"connection slot not granted",
|
||||
)));
|
||||
}
|
||||
if self.resources >= self.limits.resources {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::QuotaExceeded,
|
||||
"resource limit",
|
||||
)));
|
||||
}
|
||||
if let Err(e) = self
|
||||
.provider
|
||||
.authorize(&context, Operation::Open { slot: &slot })
|
||||
.await
|
||||
{
|
||||
return Ok(Err(e));
|
||||
}
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
|
||||
let binding = match self.provider.open(&context, &slot).await {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late connection result"))?;
|
||||
let resource = self.table.push(Connection { slot, binding })?;
|
||||
self.resources += 1;
|
||||
Ok(Ok(resource))
|
||||
}
|
||||
}
|
||||
impl bindings::yoi::host::http::Host for State {
|
||||
async fn send(
|
||||
&mut self,
|
||||
target: Resource<Connection>,
|
||||
mut input: http::Request,
|
||||
) -> wasmtime::Result<Result<http::Response, errors::HostError>> {
|
||||
let context = self.protected_current()?;
|
||||
if let Err(e) = self.operation() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let connection = self.table.get(&target)?;
|
||||
let slot = connection.slot.clone();
|
||||
let binding = connection.binding.clone();
|
||||
if !context.grants.connection_slots.contains(&slot) {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::Denied,
|
||||
"connection slot not granted",
|
||||
)));
|
||||
}
|
||||
let declaration = self
|
||||
.manifest
|
||||
.connections
|
||||
.iter()
|
||||
.find(|s| s.name == slot)
|
||||
.unwrap();
|
||||
let method = match input.method {
|
||||
http::Method::Get => plugin::PluginHttpMethod::Get,
|
||||
http::Method::Head => plugin::PluginHttpMethod::Head,
|
||||
http::Method::Post => plugin::PluginHttpMethod::Post,
|
||||
http::Method::Put => plugin::PluginHttpMethod::Put,
|
||||
http::Method::Patch => plugin::PluginHttpMethod::Patch,
|
||||
http::Method::Delete => plugin::PluginHttpMethod::Delete,
|
||||
http::Method::Options => plugin::PluginHttpMethod::Options,
|
||||
};
|
||||
// Canonical URL/DNS/TLS/secret and narrower binding checks belong to the
|
||||
// provider. These static ceilings are not a network protection claim.
|
||||
if !declaration.methods.contains(&method)
|
||||
|| !declaration.path_prefixes.iter().any(|p| {
|
||||
input.path == *p
|
||||
|| input
|
||||
.path
|
||||
.strip_prefix(p)
|
||||
.is_some_and(|rest| p.ends_with('/') || rest.starts_with('/'))
|
||||
})
|
||||
{
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::Denied,
|
||||
"request exceeds package ceiling",
|
||||
)));
|
||||
}
|
||||
if !request_bounded(&input, &self.limits) {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::QuotaExceeded,
|
||||
"request exceeds limit",
|
||||
)));
|
||||
}
|
||||
input.timeout_ms = input.timeout_ms.min(15_000).min(
|
||||
context
|
||||
.deadline
|
||||
.saturating_duration_since(Instant::now())
|
||||
.as_millis()
|
||||
.min(u32::MAX as u128) as u32,
|
||||
);
|
||||
let send_deadline = context
|
||||
.deadline
|
||||
.min(Instant::now() + Duration::from_millis(input.timeout_ms as u64));
|
||||
match tokio::time::timeout_at(
|
||||
send_deadline,
|
||||
self.provider.authorize(
|
||||
&context,
|
||||
Operation::Send {
|
||||
slot: &slot,
|
||||
binding: binding.as_ref(),
|
||||
request: &input,
|
||||
},
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => (),
|
||||
Ok(Err(error)) => return Ok(Err(error)),
|
||||
Err(_) => {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::DeadlineExceeded,
|
||||
"HTTP authorization deadline exceeded",
|
||||
)));
|
||||
}
|
||||
}
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
|
||||
if Instant::now() >= send_deadline {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::DeadlineExceeded,
|
||||
"HTTP deadline exceeded before dispatch",
|
||||
)));
|
||||
}
|
||||
let effect = self.effects.begin();
|
||||
let response = match tokio::time::timeout_at(
|
||||
send_deadline,
|
||||
self.provider.send(&context, binding.as_ref(), input),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => response,
|
||||
Err(_) => Err(errors::HostError {
|
||||
code: errors::Code::DeadlineExceeded,
|
||||
message: "HTTP wait deadline exceeded".into(),
|
||||
diagnostic_id: String::new(),
|
||||
effect: errors::EffectObservation::Indeterminate,
|
||||
}),
|
||||
};
|
||||
effect.finish(&response);
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late HTTP result"))?;
|
||||
Ok(response.and_then(|response| {
|
||||
if response.body.len() > self.limits.http_body_bytes
|
||||
|| !pairs_bounded(&response.headers, &self.limits)
|
||||
{
|
||||
Err(errors::HostError {
|
||||
code: errors::Code::QuotaExceeded,
|
||||
message: "response exceeds limit".into(),
|
||||
diagnostic_id: String::new(),
|
||||
effect: errors::EffectObservation::ResponseObserved,
|
||||
})
|
||||
} else {
|
||||
Ok(response)
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
fn pairs_bounded(pairs: &[http::Pair], limits: &Limits) -> bool {
|
||||
pairs.len() <= limits.http_header_pairs
|
||||
&& pairs
|
||||
.iter()
|
||||
.try_fold(0usize, |n, p| {
|
||||
n.checked_add(p.name.len())?.checked_add(p.value.len())
|
||||
})
|
||||
.is_some_and(|n| n <= limits.http_header_bytes)
|
||||
}
|
||||
fn request_bounded(request: &http::Request, limits: &Limits) -> bool {
|
||||
request.body.len() <= limits.http_body_bytes
|
||||
&& request.path.len() <= limits.payload_bytes
|
||||
&& request.effect_key.len() <= 128
|
||||
&& !request.effect_key.is_empty()
|
||||
&& pairs_bounded(&request.headers, limits)
|
||||
&& pairs_bounded(&request.query, limits)
|
||||
}
|
||||
impl bindings::yoi::host::logging::Host for State {
|
||||
async fn log(
|
||||
&mut self,
|
||||
input: logging::Entry,
|
||||
) -> wasmtime::Result<Result<(), errors::HostError>> {
|
||||
let context = self.current()?;
|
||||
if !context.grants.logging
|
||||
|| !self
|
||||
.manifest
|
||||
.requested_grants
|
||||
.contains(&plugin::PluginRequestedGrant::Logging)
|
||||
{
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::Denied,
|
||||
"logging not granted",
|
||||
)));
|
||||
}
|
||||
let size = input
|
||||
.event_code
|
||||
.len()
|
||||
.saturating_add(input.message.len())
|
||||
.saturating_add(
|
||||
input
|
||||
.fields
|
||||
.iter()
|
||||
.map(|f| {
|
||||
f.name.len().saturating_add(match &f.value {
|
||||
logging::Scalar::Text(s) => s.len(),
|
||||
_ => 8,
|
||||
})
|
||||
})
|
||||
.sum::<usize>(),
|
||||
);
|
||||
if size > self.limits.log_bytes.saturating_sub(self.log_bytes) {
|
||||
self.dropped_logs = self.dropped_logs.saturating_add(1);
|
||||
return Ok(Ok(()));
|
||||
}
|
||||
if let Err(e) = self.operation() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if let Err(e) = self.provider.authorize(&context, Operation::Log).await {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("invocation no longer active"))?;
|
||||
self.log_bytes += size;
|
||||
let result = self.provider.log(&context, input).await;
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late log result"))?;
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
use super::*;
|
||||
use crate::service_bindings::{self, exports::yoi::plugin::service as abi};
|
||||
use manifest::plugin::{PluginServiceManifest, PluginTicketEventType};
|
||||
use server_api::{PluginServiceEventResult, PluginTicketEventEnvelope};
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
|
||||
/// Shares compiled bytes and the existing Host/resource implementation, not authority.
|
||||
pub struct CompiledService(CompiledTool);
|
||||
impl CompiledService {
|
||||
pub fn identity(&self) -> &ArtifactIdentity {
|
||||
self.0.identity()
|
||||
}
|
||||
pub fn services(&self) -> &[PluginServiceManifest] {
|
||||
&self.0.manifest.services
|
||||
}
|
||||
}
|
||||
|
||||
/// Guest acknowledgement is only a proposal. The driver must persist the cursor
|
||||
/// through its owner and pause on an indeterminate Host effect, even on guest ack.
|
||||
#[derive(Debug)]
|
||||
pub struct ServiceInvocationResult {
|
||||
pub outcome: Result<ServiceOutput, ExecutionError>,
|
||||
pub effect: errors::EffectObservation,
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub enum ServiceOutput {
|
||||
Started,
|
||||
Event(PluginServiceEventResult),
|
||||
Stopped,
|
||||
}
|
||||
|
||||
enum Call<'a> {
|
||||
Start(&'a str),
|
||||
Event {
|
||||
envelope: &'a PluginTicketEventEnvelope,
|
||||
config: &'a str,
|
||||
},
|
||||
Stop(&'a str),
|
||||
}
|
||||
|
||||
/// Kept outside the Store so cancellation, timeout and traps cannot erase a
|
||||
/// dispatched-but-unobserved effect. This is observation, not a receipt ledger.
|
||||
#[derive(Default)]
|
||||
pub(super) struct Effects {
|
||||
pending: AtomicUsize,
|
||||
unknown: AtomicBool,
|
||||
response: AtomicBool,
|
||||
}
|
||||
impl Effects {
|
||||
pub(super) fn begin(self: &Arc<Self>) -> EffectWait {
|
||||
self.pending.fetch_add(1, Ordering::SeqCst);
|
||||
EffectWait {
|
||||
effects: self.clone(),
|
||||
completed: false,
|
||||
}
|
||||
}
|
||||
pub(super) fn unresolved(&self) {
|
||||
self.unknown.store(true, Ordering::SeqCst);
|
||||
}
|
||||
pub(super) fn observe(&self, error: &errors::HostError) {
|
||||
if matches!(error.effect, errors::EffectObservation::Indeterminate)
|
||||
|| matches!(
|
||||
error.code,
|
||||
errors::Code::Indeterminate | errors::Code::ReceiptExpired
|
||||
)
|
||||
{
|
||||
self.unknown.store(true, Ordering::SeqCst);
|
||||
}
|
||||
if matches!(error.effect, errors::EffectObservation::ResponseObserved) {
|
||||
self.response.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
fn snapshot(&self) -> errors::EffectObservation {
|
||||
if self.unknown.load(Ordering::SeqCst) || self.pending.load(Ordering::SeqCst) != 0 {
|
||||
errors::EffectObservation::Indeterminate
|
||||
} else if self.response.load(Ordering::SeqCst) {
|
||||
errors::EffectObservation::ResponseObserved
|
||||
} else {
|
||||
errors::EffectObservation::NotDispatched
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) struct EffectWait {
|
||||
effects: Arc<Effects>,
|
||||
completed: bool,
|
||||
}
|
||||
impl EffectWait {
|
||||
pub(super) fn finish<T>(mut self, result: &Result<T, errors::HostError>) {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
self.effects.response.store(true, Ordering::SeqCst);
|
||||
}
|
||||
Err(e) => self.effects.observe(e),
|
||||
}
|
||||
self.completed = true;
|
||||
}
|
||||
}
|
||||
impl Drop for EffectWait {
|
||||
fn drop(&mut self) {
|
||||
if !self.completed {
|
||||
self.effects.unknown.store(true, Ordering::SeqCst);
|
||||
}
|
||||
self.effects.pending.fetch_sub(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
impl Executor {
|
||||
pub fn compile_service(
|
||||
&self,
|
||||
artifact: &[u8],
|
||||
package_digest: &str,
|
||||
artifact_digest: &str,
|
||||
artifact_size: u64,
|
||||
) -> Result<CompiledService, ExecutionError> {
|
||||
let compiled = self.compile(artifact, package_digest, artifact_digest, artifact_size)?;
|
||||
if compiled.manifest.runtime.world != plugin::PLUGIN_COMPONENT_SERVICE_WORLD {
|
||||
return Err(ExecutionError::Artifact(
|
||||
"not the exact Service world".into(),
|
||||
));
|
||||
}
|
||||
Ok(CompiledService(compiled))
|
||||
}
|
||||
|
||||
pub async fn service_start(
|
||||
&self,
|
||||
compiled: &CompiledService,
|
||||
context: InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
service_name: &str,
|
||||
config_json: &str,
|
||||
) -> ServiceInvocationResult {
|
||||
self.service_call(
|
||||
compiled,
|
||||
context,
|
||||
provider,
|
||||
service_name,
|
||||
Call::Start(config_json),
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn service_event(
|
||||
&self,
|
||||
compiled: &CompiledService,
|
||||
context: InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
service_name: &str,
|
||||
envelope: &PluginTicketEventEnvelope,
|
||||
config_json: &str,
|
||||
) -> ServiceInvocationResult {
|
||||
self.service_call(
|
||||
compiled,
|
||||
context,
|
||||
provider,
|
||||
service_name,
|
||||
Call::Event {
|
||||
envelope,
|
||||
config: config_json,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
pub async fn service_stop(
|
||||
&self,
|
||||
compiled: &CompiledService,
|
||||
context: InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
service_name: &str,
|
||||
reason: &str,
|
||||
) -> ServiceInvocationResult {
|
||||
self.service_call(
|
||||
compiled,
|
||||
context,
|
||||
provider,
|
||||
service_name,
|
||||
Call::Stop(reason),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn service_call(
|
||||
&self,
|
||||
compiled: &CompiledService,
|
||||
mut context: InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
service_name: &str,
|
||||
call: Call<'_>,
|
||||
) -> ServiceInvocationResult {
|
||||
let ceiling = match call {
|
||||
Call::Start(_) => Duration::from_secs(5),
|
||||
Call::Stop(_) => Duration::from_secs(2),
|
||||
Call::Event { .. } => self.limits.wall_time,
|
||||
};
|
||||
context.deadline = context
|
||||
.deadline
|
||||
.min(Instant::now() + ceiling.min(self.limits.wall_time));
|
||||
context.cancellation = context.cancellation.child_token();
|
||||
let _lifetime = InvocationLifetime(context.cancellation.clone());
|
||||
let effects = Arc::new(Effects::default());
|
||||
let execution = self.run_service(
|
||||
compiled,
|
||||
&context,
|
||||
provider,
|
||||
service_name,
|
||||
call,
|
||||
effects.clone(),
|
||||
);
|
||||
tokio::pin!(execution);
|
||||
let mut tick = tokio::time::interval(Duration::from_millis(10));
|
||||
let outcome = loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = context.cancellation.cancelled() => break Err(ExecutionError::Cancelled),
|
||||
_ = tokio::time::sleep_until(context.deadline) => break Err(ExecutionError::Deadline),
|
||||
result = &mut execution => break result,
|
||||
_ = tick.tick() => self.engine.increment_epoch(),
|
||||
}
|
||||
};
|
||||
ServiceInvocationResult {
|
||||
outcome,
|
||||
effect: effects.snapshot(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_service(
|
||||
&self,
|
||||
compiled: &CompiledService,
|
||||
context: &InvocationContext,
|
||||
provider: Arc<dyn Host>,
|
||||
service_name: &str,
|
||||
call: Call<'_>,
|
||||
effects: Arc<Effects>,
|
||||
) -> Result<ServiceOutput, ExecutionError> {
|
||||
let compiled = &compiled.0;
|
||||
if !Engine::same(&self.engine, compiled.component.engine())
|
||||
|| context.artifact != compiled.identity
|
||||
|| context.tool_name != service_name
|
||||
{
|
||||
return Err(ExecutionError::InvalidInput(
|
||||
"Service artifact/name does not match Host context".into(),
|
||||
));
|
||||
}
|
||||
let declaration = compiled
|
||||
.manifest
|
||||
.services
|
||||
.iter()
|
||||
.find(|s| s.name == service_name)
|
||||
.ok_or_else(|| ExecutionError::InvalidInput("undeclared Service".into()))?;
|
||||
if context.invocation_id.is_empty() || context.invocation_id.len() > 1024 {
|
||||
return Err(ExecutionError::InvalidInput(
|
||||
"invalid invocation identity".into(),
|
||||
));
|
||||
}
|
||||
let config = match &call {
|
||||
Call::Start(c) | Call::Event { config: c, .. } => Some(*c),
|
||||
Call::Stop(_) => None,
|
||||
};
|
||||
if let Some(config) = config {
|
||||
if config.len() > self.limits.config_bytes {
|
||||
return Err(ExecutionError::InvalidInput("config exceeds limit".into()));
|
||||
}
|
||||
serde_json::from_str::<serde_json::Value>(config)
|
||||
.map_err(|e| ExecutionError::InvalidInput(e.to_string()))?;
|
||||
}
|
||||
if let Call::Event { envelope: e, .. } = &call {
|
||||
let kind = match e.event_type {
|
||||
server_api::PluginTicketEventType::Changed => PluginTicketEventType::TicketChanged,
|
||||
server_api::PluginTicketEventType::Deleted => PluginTicketEventType::TicketDeleted,
|
||||
};
|
||||
if !context.grants.ticket_events
|
||||
|| e.version != 1
|
||||
|| e.attempt == 0
|
||||
|| !declaration.subscriptions.contains(&kind)
|
||||
|| serde_json::to_vec(e)
|
||||
.map_err(|e| ExecutionError::InvalidInput(e.to_string()))?
|
||||
.len()
|
||||
> self.limits.payload_bytes
|
||||
|| [&e.event_id, &e.ticket_id, &e.domain_event_ref]
|
||||
.iter()
|
||||
.any(|s| s.is_empty() || s.len() > 1024)
|
||||
{
|
||||
return Err(ExecutionError::InvalidInput(
|
||||
"invalid, oversized or ungranted Service event".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Call::Stop(reason) = &call {
|
||||
if reason.len() > self.limits.payload_bytes {
|
||||
return Err(ExecutionError::InvalidInput(
|
||||
"stop reason exceeds limit".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
provider
|
||||
.authorize(context, Operation::Admit)
|
||||
.await
|
||||
.map_err(ExecutionError::Host)?;
|
||||
check_current(context).map_err(ExecutionError::Host)?;
|
||||
let limits = &self.limits;
|
||||
let mut store = Store::new(
|
||||
&self.engine,
|
||||
State {
|
||||
context: None,
|
||||
provider: provider.clone(),
|
||||
limits: limits.clone(),
|
||||
manifest: compiled.manifest.clone(),
|
||||
table: ResourceTable::new(),
|
||||
operations: 0,
|
||||
resources: 0,
|
||||
log_bytes: 0,
|
||||
dropped_logs: 0,
|
||||
store_limits: SandboxLimits::new(limits),
|
||||
logging_only: !matches!(call, Call::Event { .. }),
|
||||
effects,
|
||||
},
|
||||
);
|
||||
store.limiter(|s| &mut s.store_limits);
|
||||
store.set_fuel(limits.fuel).map_err(trap)?;
|
||||
store
|
||||
.fuel_async_yield_interval(Some(10_000))
|
||||
.map_err(trap)?;
|
||||
store.set_epoch_deadline(1);
|
||||
store.epoch_deadline_async_yield_and_update(1);
|
||||
let mut linker = Linker::new(&self.engine);
|
||||
service_bindings::TicketServicePlugin::add_to_linker::<_, wasmtime::component::HasSelf<_>>(
|
||||
&mut linker,
|
||||
|s| s,
|
||||
)
|
||||
.map_err(trap)?;
|
||||
let instance = service_bindings::TicketServicePlugin::instantiate_async(
|
||||
&mut store,
|
||||
&compiled.component,
|
||||
&linker,
|
||||
)
|
||||
.await
|
||||
.map_err(trap)?;
|
||||
check_current(context).map_err(ExecutionError::Host)?;
|
||||
// Instantiation/start sections never receive an export context.
|
||||
store.data_mut().context = Some(context.clone());
|
||||
let service = instance.yoi_plugin_service();
|
||||
let output = match call {
|
||||
Call::Start(config) => {
|
||||
service
|
||||
.call_on_start(&mut store, config)
|
||||
.await
|
||||
.map_err(trap)?
|
||||
.map_err(guest_error)?;
|
||||
ServiceOutput::Started
|
||||
}
|
||||
Call::Stop(reason) => {
|
||||
service
|
||||
.call_on_stop(&mut store, reason)
|
||||
.await
|
||||
.map_err(trap)?
|
||||
.map_err(guest_error)?;
|
||||
ServiceOutput::Stopped
|
||||
}
|
||||
Call::Event {
|
||||
envelope: e,
|
||||
config,
|
||||
} => {
|
||||
let input = abi::Envelope {
|
||||
event_id: e.event_id.clone(),
|
||||
event_type: match e.event_type {
|
||||
server_api::PluginTicketEventType::Changed => {
|
||||
abi::TicketEventType::TicketChanged
|
||||
}
|
||||
server_api::PluginTicketEventType::Deleted => {
|
||||
abi::TicketEventType::TicketDeleted
|
||||
}
|
||||
},
|
||||
event_version: e.version,
|
||||
occurred_at: e.occurred_at.clone(),
|
||||
ticket_ref: e.ticket_id.clone(),
|
||||
domain_event_id: e.domain_event_ref.clone(),
|
||||
content_digest: if e.content_digest.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(e.content_digest.clone())
|
||||
},
|
||||
correlation_id: if e.correlation_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(e.correlation_id.clone())
|
||||
},
|
||||
causation_id: e.causation_id.clone(),
|
||||
invocation_id: context.invocation_id.clone(),
|
||||
service_name: service_name.into(),
|
||||
config_json: config.into(),
|
||||
attempt: e.attempt,
|
||||
remaining_ms: context
|
||||
.deadline
|
||||
.saturating_duration_since(Instant::now())
|
||||
.as_millis()
|
||||
.min(u32::MAX as u128) as u32,
|
||||
};
|
||||
let result = match service
|
||||
.call_on_event(&mut store, &input)
|
||||
.await
|
||||
.map_err(trap)?
|
||||
{
|
||||
abi::EventResult::Ack => PluginServiceEventResult::Ack,
|
||||
abi::EventResult::RetryAfter(delay_ms) if delay_ms <= 300_000 => {
|
||||
PluginServiceEventResult::RetryAfter { delay_ms }
|
||||
}
|
||||
abi::EventResult::Reject(reason)
|
||||
if reason.len() <= self.limits.payload_bytes =>
|
||||
{
|
||||
PluginServiceEventResult::Reject
|
||||
}
|
||||
_ => {
|
||||
return Err(ExecutionError::InvalidOutput(
|
||||
"Service result exceeds limit".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
ServiceOutput::Event(result)
|
||||
}
|
||||
};
|
||||
check_current(context).map_err(ExecutionError::Host)?;
|
||||
provider
|
||||
.authorize(context, Operation::Publish)
|
||||
.await
|
||||
.map_err(ExecutionError::Host)?;
|
||||
check_current(context).map_err(ExecutionError::Host)?;
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
fn guest_error(error: abi::ServiceError) -> ExecutionError {
|
||||
if error.code.len().saturating_add(error.message.len()) > 4096 {
|
||||
ExecutionError::InvalidOutput("Service error exceeds limit".into())
|
||||
} else {
|
||||
ExecutionError::Guest {
|
||||
code: "plugin-failed".into(),
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
use super::*;
|
||||
use crate::host::{
|
||||
Ticket, TicketDataOperation as Op, TicketDataResult as Reply, extension_data as data, objects,
|
||||
ticket_read,
|
||||
};
|
||||
use server_api::{
|
||||
PluginDataAddress as Address, PluginDataCell as Cell, PluginDataCollection as Collection,
|
||||
PluginDataExpectation as Expectation, PluginDataMutation as Mutation,
|
||||
};
|
||||
|
||||
fn denied() -> errors::HostError {
|
||||
host::error(errors::Code::Denied, "Ticket capability not granted")
|
||||
}
|
||||
fn invalid() -> errors::HostError {
|
||||
host::error(errors::Code::InvalidInput, "invalid Ticket data request")
|
||||
}
|
||||
fn protocol() -> errors::HostError {
|
||||
host::error(errors::Code::Unavailable, "invalid Ticket owner response")
|
||||
}
|
||||
fn collection(c: data::Collection) -> Collection {
|
||||
Collection {
|
||||
collection: c.collection,
|
||||
schema_id: c.schema_id,
|
||||
schema_version: c.schema_version,
|
||||
}
|
||||
}
|
||||
fn address(a: data::Address) -> Address {
|
||||
Address {
|
||||
collection: collection(a.namespace),
|
||||
key: a.key,
|
||||
}
|
||||
}
|
||||
fn cell(c: Cell) -> data::Cell {
|
||||
match c {
|
||||
Cell::NeverWritten => data::Cell::NeverWritten,
|
||||
Cell::Deleted { last_delete } => data::Cell::Deleted(last_delete),
|
||||
Cell::Present {
|
||||
value_json,
|
||||
last_write,
|
||||
} => data::Cell::Present(data::PresentCell {
|
||||
value_json,
|
||||
last_write,
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn receipt(r: server_api::PluginDataReceipt) -> data::Receipt {
|
||||
data::Receipt {
|
||||
mutation_id: r.mutation_id,
|
||||
result_digest: r.result_digest,
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
fn ticket_world(&self) -> Result<(), errors::HostError> {
|
||||
if !matches!(
|
||||
self.manifest.runtime.world.as_str(),
|
||||
plugin::PLUGIN_COMPONENT_TICKET_TOOL_WORLD | plugin::PLUGIN_COMPONENT_SERVICE_WORLD
|
||||
) {
|
||||
return Err(denied());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn data_schema(&self, c: &Collection) -> Result<&serde_json::Value, errors::HostError> {
|
||||
self.manifest
|
||||
.data_collections
|
||||
.iter()
|
||||
.find(|d| {
|
||||
d.collection == c.collection
|
||||
&& d.schema_id == c.schema_id
|
||||
&& d.schema_version == c.schema_version
|
||||
})
|
||||
.map(|d| &d.schema)
|
||||
.ok_or_else(denied)
|
||||
}
|
||||
fn data_grant(&self, ctx: &InvocationContext, c: &Collection, write: bool) -> bool {
|
||||
let id = (
|
||||
c.collection.clone(),
|
||||
c.schema_id.clone(),
|
||||
c.schema_version.clone(),
|
||||
);
|
||||
let requested = if write {
|
||||
plugin::PluginRequestedGrant::TicketDataWrite {
|
||||
collection: c.collection.clone(),
|
||||
schema_id: c.schema_id.clone(),
|
||||
schema_version: c.schema_version.clone(),
|
||||
}
|
||||
} else {
|
||||
plugin::PluginRequestedGrant::TicketDataRead {
|
||||
collection: c.collection.clone(),
|
||||
schema_id: c.schema_id.clone(),
|
||||
schema_version: c.schema_version.clone(),
|
||||
}
|
||||
};
|
||||
self.manifest.requested_grants.contains(&requested)
|
||||
&& (if write {
|
||||
&ctx.grants.ticket_data_write
|
||||
} else {
|
||||
&ctx.grants.ticket_data_read
|
||||
})
|
||||
.contains(&id)
|
||||
}
|
||||
fn check_cell(&self, namespace: &Collection, c: &Cell) -> Result<(), errors::HostError> {
|
||||
if let Cell::Present { value_json, .. } = c {
|
||||
if value_json.len() > server_api::PLUGIN_DATA_VALUE_LIMIT {
|
||||
return Err(protocol());
|
||||
}
|
||||
let value = serde_json::from_str(value_json).map_err(|_| protocol())?;
|
||||
plugin::schema::validate_data_value(self.data_schema(namespace)?, &value)
|
||||
.map_err(|_| protocol())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
async fn data_call(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
operation: Op,
|
||||
) -> wasmtime::Result<Result<Reply, errors::HostError>> {
|
||||
let context = self.protected_current()?;
|
||||
let checked = (|| {
|
||||
self.operation()?;
|
||||
self.ticket_world()?;
|
||||
crate::owner::validate_operation(&operation).map_err(|_| invalid())?;
|
||||
let (namespace, write, lookup) = match &operation {
|
||||
Op::Get(a) => (&a.collection, false, false),
|
||||
Op::Mutate { address, .. } => (&address.collection, true, false),
|
||||
Op::List { collection, .. } => (collection, false, false),
|
||||
Op::LookupMutation { address, .. } => (&address.collection, false, true),
|
||||
};
|
||||
let schema = self.data_schema(namespace)?;
|
||||
if !self.data_grant(&context, namespace, write || lookup) {
|
||||
return Err(denied());
|
||||
}
|
||||
if let Op::Mutate {
|
||||
mutation: Mutation::Put { value_json, .. },
|
||||
..
|
||||
} = &operation
|
||||
{
|
||||
let value = serde_json::from_str(value_json).map_err(|_| invalid())?;
|
||||
plugin::schema::validate_data_value(schema, &value).map_err(|_| invalid())?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = checked {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let binding = self.table.get(&target)?.binding.clone();
|
||||
let namespace = match &operation {
|
||||
Op::Get(a) | Op::Mutate { address: a, .. } | Op::LookupMutation { address: a, .. } => {
|
||||
a.collection.clone()
|
||||
}
|
||||
Op::List { collection, .. } => collection.clone(),
|
||||
};
|
||||
let list_limit = if let Op::List { limit, .. } = &operation {
|
||||
Some(*limit as usize)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mutating = matches!(&operation, Op::Mutate { .. });
|
||||
let lookup = matches!(&operation, Op::LookupMutation { .. });
|
||||
let expected_reply = match &operation {
|
||||
Op::Get(_) => 0,
|
||||
Op::Mutate { .. } => 1,
|
||||
Op::List { .. } => 2,
|
||||
Op::LookupMutation { .. } => 3,
|
||||
};
|
||||
let effect = mutating.then(|| self.effects.begin());
|
||||
let reply = self
|
||||
.provider
|
||||
.ticket_data(&context, binding.as_ref(), operation)
|
||||
.await;
|
||||
if let Some(effect) = effect {
|
||||
effect.finish(&reply);
|
||||
}
|
||||
let reply = match reply {
|
||||
Ok(reply) => reply,
|
||||
Err(e) => {
|
||||
self.effects.observe(&e);
|
||||
if lookup {
|
||||
self.effects.unresolved();
|
||||
}
|
||||
return Ok(Err(e));
|
||||
}
|
||||
};
|
||||
if matches!(
|
||||
&reply,
|
||||
Reply::MutationLookup(server_api::PluginDataMutationLookup::Expired)
|
||||
) {
|
||||
self.effects.unresolved();
|
||||
}
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket data result"))?;
|
||||
let actual_reply = match &reply {
|
||||
Reply::Cell(_) => 0,
|
||||
Reply::Mutated(_) => 1,
|
||||
Reply::Listed(_) => 2,
|
||||
Reply::MutationLookup(_) => 3,
|
||||
};
|
||||
if actual_reply != expected_reply {
|
||||
let mut error = protocol();
|
||||
if mutating {
|
||||
error.code = errors::Code::Indeterminate;
|
||||
error.effect = errors::EffectObservation::Indeterminate;
|
||||
}
|
||||
self.effects.observe(&error);
|
||||
return Ok(Err(error));
|
||||
}
|
||||
let valid = match &reply {
|
||||
Reply::Cell(c) => self.check_cell(&namespace, c),
|
||||
Reply::Listed(page) => {
|
||||
if page.entries.len() > list_limit.unwrap_or(0)
|
||||
|| page.entries.windows(2).any(|p| p[0].key >= p[1].key)
|
||||
|| page.entries.iter().any(|e| {
|
||||
e.key.is_empty()
|
||||
|| e.key.len() > 256
|
||||
|| matches!(e.cell, Cell::NeverWritten)
|
||||
})
|
||||
|| page
|
||||
.next_cursor
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.is_empty() || s.len() > 4096)
|
||||
{
|
||||
Err(protocol())
|
||||
} else {
|
||||
page.entries
|
||||
.iter()
|
||||
.try_for_each(|e| self.check_cell(&namespace, &e.cell))
|
||||
}
|
||||
}
|
||||
_ => Ok(()),
|
||||
};
|
||||
if let Err(e) = valid {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(Ok(reply))
|
||||
}
|
||||
}
|
||||
impl objects::HostTicket for State {
|
||||
async fn drop(&mut self, target: Resource<Ticket>) -> wasmtime::Result<()> {
|
||||
self.table.delete(target)?;
|
||||
self.resources -= 1;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl objects::Host for State {
|
||||
async fn resolve_ticket(
|
||||
&mut self,
|
||||
ticket_ref: String,
|
||||
requested: objects::Access,
|
||||
) -> wasmtime::Result<Result<Resource<Ticket>, errors::HostError>> {
|
||||
let context = self.protected_current()?;
|
||||
if let Err(e) = self.operation().and_then(|_| self.ticket_world()) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if ticket_ref.is_empty() || ticket_ref.len() > 256 {
|
||||
return Ok(Err(invalid()));
|
||||
}
|
||||
let writable = matches!(requested, objects::Access::ExtensionWrite);
|
||||
let allowed = self.manifest.requested_grants.iter().any(|g| match g {
|
||||
plugin::PluginRequestedGrant::TicketRead => !writable && context.grants.ticket_read,
|
||||
plugin::PluginRequestedGrant::TicketDataRead {
|
||||
collection,
|
||||
schema_id,
|
||||
schema_version,
|
||||
} => {
|
||||
!writable
|
||||
&& context.grants.ticket_data_read.contains(&(
|
||||
collection.clone(),
|
||||
schema_id.clone(),
|
||||
schema_version.clone(),
|
||||
))
|
||||
}
|
||||
plugin::PluginRequestedGrant::TicketDataWrite {
|
||||
collection,
|
||||
schema_id,
|
||||
schema_version,
|
||||
} => {
|
||||
writable
|
||||
&& context.grants.ticket_data_write.contains(&(
|
||||
collection.clone(),
|
||||
schema_id.clone(),
|
||||
schema_version.clone(),
|
||||
))
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
if !allowed {
|
||||
return Ok(Err(denied()));
|
||||
}
|
||||
if self.resources >= self.limits.resources {
|
||||
return Ok(Err(host::error(
|
||||
errors::Code::QuotaExceeded,
|
||||
"resource limit",
|
||||
)));
|
||||
}
|
||||
let binding = match self
|
||||
.provider
|
||||
.resolve_ticket(&context, &ticket_ref, requested)
|
||||
.await
|
||||
{
|
||||
Ok(binding) => binding,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket resource"))?;
|
||||
let target = self.table.push(Ticket { binding })?;
|
||||
self.resources += 1;
|
||||
Ok(Ok(target))
|
||||
}
|
||||
}
|
||||
impl ticket_read::Host for State {
|
||||
async fn read(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
) -> wasmtime::Result<Result<ticket_read::Snapshot, errors::HostError>> {
|
||||
let context = self.protected_current()?;
|
||||
if let Err(e) = self.operation().and_then(|_| self.ticket_world()) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if !context.grants.ticket_read
|
||||
|| !self
|
||||
.manifest
|
||||
.requested_grants
|
||||
.contains(&plugin::PluginRequestedGrant::TicketRead)
|
||||
{
|
||||
return Ok(Err(denied()));
|
||||
}
|
||||
let binding = self.table.get(&target)?.binding.clone();
|
||||
let result = self.provider.read_ticket(&context, binding.as_ref()).await;
|
||||
check_current(&context).map_err(|_| wasmtime::Error::msg("late Ticket snapshot"))?;
|
||||
Ok(result.and_then(|s| {
|
||||
if s.body.len() <= server_api::PLUGIN_TICKET_BODY_LIMIT {
|
||||
Ok(s)
|
||||
} else {
|
||||
Err(protocol())
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
impl data::Host for State {
|
||||
async fn get(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
cell_address: data::Address,
|
||||
) -> wasmtime::Result<Result<data::Cell, errors::HostError>> {
|
||||
Ok(
|
||||
match self
|
||||
.data_call(target, Op::Get(address(cell_address)))
|
||||
.await?
|
||||
{
|
||||
Ok(Reply::Cell(c)) => Ok(cell(c)),
|
||||
Err(e) => Err(e),
|
||||
_ => Err(protocol()),
|
||||
},
|
||||
)
|
||||
}
|
||||
async fn put(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
cell_address: data::Address,
|
||||
expected: data::Expectation,
|
||||
operation_key: String,
|
||||
value_json: String,
|
||||
) -> wasmtime::Result<Result<data::Receipt, errors::HostError>> {
|
||||
let expected = match expected {
|
||||
data::Expectation::NeverWritten => Expectation::NeverWritten,
|
||||
data::Expectation::LastWrite(id) => Expectation::LastWrite(id),
|
||||
};
|
||||
Ok(
|
||||
match self
|
||||
.data_call(
|
||||
target,
|
||||
Op::Mutate {
|
||||
address: address(cell_address),
|
||||
operation_key,
|
||||
mutation: Mutation::Put {
|
||||
expected,
|
||||
value_json,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(Reply::Mutated(r)) => Ok(receipt(r)),
|
||||
Err(e) => Err(e),
|
||||
_ => Err(protocol()),
|
||||
},
|
||||
)
|
||||
}
|
||||
async fn delete(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
cell_address: data::Address,
|
||||
expected_last_write: String,
|
||||
operation_key: String,
|
||||
) -> wasmtime::Result<Result<data::Receipt, errors::HostError>> {
|
||||
Ok(
|
||||
match self
|
||||
.data_call(
|
||||
target,
|
||||
Op::Mutate {
|
||||
address: address(cell_address),
|
||||
operation_key,
|
||||
mutation: Mutation::Delete {
|
||||
expected_last_write,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(Reply::Mutated(r)) => Ok(receipt(r)),
|
||||
Err(e) => Err(e),
|
||||
_ => Err(protocol()),
|
||||
},
|
||||
)
|
||||
}
|
||||
async fn list(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
namespace: data::Collection,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
) -> wasmtime::Result<Result<data::Page, errors::HostError>> {
|
||||
Ok(
|
||||
match self
|
||||
.data_call(
|
||||
target,
|
||||
Op::List {
|
||||
collection: collection(namespace),
|
||||
cursor,
|
||||
limit,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(Reply::Listed(p)) => Ok(data::Page {
|
||||
entries: p
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|e| data::Entry {
|
||||
key: e.key,
|
||||
value: cell(e.cell),
|
||||
})
|
||||
.collect(),
|
||||
next_cursor: p.next_cursor,
|
||||
}),
|
||||
Err(e) => Err(e),
|
||||
_ => Err(protocol()),
|
||||
},
|
||||
)
|
||||
}
|
||||
async fn lookup_mutation(
|
||||
&mut self,
|
||||
target: Resource<Ticket>,
|
||||
cell_address: data::Address,
|
||||
operation_key: String,
|
||||
) -> wasmtime::Result<Result<data::MutationLookup, errors::HostError>> {
|
||||
use server_api::PluginDataMutationLookup as Lookup;
|
||||
Ok(
|
||||
match self
|
||||
.data_call(
|
||||
target,
|
||||
Op::LookupMutation {
|
||||
address: address(cell_address),
|
||||
operation_key,
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
Ok(Reply::MutationLookup(outcome)) => Ok(match outcome {
|
||||
Lookup::NotRecorded => data::MutationLookup::NotRecorded,
|
||||
Lookup::Expired => data::MutationLookup::Expired,
|
||||
Lookup::Committed(r) => data::MutationLookup::Committed(receipt(r)),
|
||||
}),
|
||||
Err(e) => Err(e),
|
||||
_ => Err(protocol()),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Host-bound identity, grants and asynchronous provider contracts.
|
||||
//!
|
||||
//! The provider is the authority adapter: opaque bindings are not credentials
|
||||
//! supplied by guests. Implementations must live-check authorization, remain
|
||||
//! cancellation-safe, bound their own allocations and never auto-retry effects.
|
||||
|
||||
pub use crate::bindings::yoi::host::{errors, http, logging};
|
||||
pub use crate::ticket_bindings::yoi::host2_1_0::{extension_data, objects, ticket_read};
|
||||
use async_trait::async_trait;
|
||||
use std::{any::Any, collections::BTreeSet, sync::Arc};
|
||||
use tokio::time::Instant;
|
||||
pub use tokio_util::sync::CancellationToken;
|
||||
|
||||
/// Constructed by the authenticated Runtime integration, never from guest JSON.
|
||||
/// All fields are a snapshot upper bound, not a cached live authorization.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct InvocationContext {
|
||||
pub workspace: String,
|
||||
pub installation: String,
|
||||
pub plan: String,
|
||||
pub subject: String,
|
||||
pub invocation_id: String,
|
||||
pub artifact: crate::ArtifactIdentity,
|
||||
pub tool_name: String,
|
||||
pub grants: Grants,
|
||||
pub deadline: Instant,
|
||||
pub cancellation: CancellationToken,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Grants {
|
||||
pub logging: bool,
|
||||
pub connection_slots: BTreeSet<String>,
|
||||
pub ticket_read: bool,
|
||||
pub ticket_events: bool,
|
||||
pub ticket_data_read: BTreeSet<(String, String, String)>,
|
||||
pub ticket_data_write: BTreeSet<(String, String, String)>,
|
||||
}
|
||||
|
||||
/// Opaque Host-owned capability, stored only within one invocation's Store.
|
||||
/// The provider must not interpret a guest resource representation as identity.
|
||||
pub struct Connection {
|
||||
pub(crate) slot: String,
|
||||
pub(crate) binding: Arc<dyn Any + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Opaque Ticket capability stored only in one invocation's Store.
|
||||
pub struct Ticket {
|
||||
pub(crate) binding: Arc<dyn Any + Send + Sync>,
|
||||
}
|
||||
|
||||
/// Each operation requires a fresh owner check; admission covers pure Tools too.
|
||||
pub enum Operation<'a> {
|
||||
Admit,
|
||||
Publish,
|
||||
Open {
|
||||
slot: &'a str,
|
||||
},
|
||||
Send {
|
||||
slot: &'a str,
|
||||
binding: &'a (dyn Any + Send + Sync),
|
||||
request: &'a http::Request,
|
||||
},
|
||||
Log,
|
||||
}
|
||||
|
||||
/// Core operations require implementations. Additive APIs default to Unsupported
|
||||
/// for old providers, never to permission or success. Test providers belong to tests.
|
||||
///
|
||||
/// `authorize` must check the current installation/artifact/plan/subject and
|
||||
/// operation against live owner policy. `open`/`send` must also enforce concrete
|
||||
/// binding policy, secret handling and effect receipts at dispatch time. The
|
||||
/// executor only enforces static package and invocation ceilings. All waits are
|
||||
/// dropped on cancellation/deadline; providers must not leave detached work.
|
||||
#[async_trait]
|
||||
pub trait Host: Send + Sync {
|
||||
/// Old providers fail closed, never infer permission from an old grant.
|
||||
async fn resolve_ticket(
|
||||
&self,
|
||||
_context: &InvocationContext,
|
||||
_ticket_ref: &str,
|
||||
_access: objects::Access,
|
||||
) -> Result<Arc<dyn Any + Send + Sync>, errors::HostError> {
|
||||
Err(error(errors::Code::Unsupported, "Ticket API unavailable"))
|
||||
}
|
||||
async fn read_ticket(
|
||||
&self,
|
||||
_context: &InvocationContext,
|
||||
_binding: &(dyn Any + Send + Sync),
|
||||
) -> Result<ticket_read::Snapshot, errors::HostError> {
|
||||
Err(error(errors::Code::Unsupported, "Ticket API unavailable"))
|
||||
}
|
||||
/// Providers live-authorize the concrete operation, Ticket and namespace.
|
||||
async fn ticket_data(
|
||||
&self,
|
||||
_context: &InvocationContext,
|
||||
_binding: &(dyn Any + Send + Sync),
|
||||
_operation: TicketDataOperation,
|
||||
) -> Result<TicketDataResult, errors::HostError> {
|
||||
Err(error(
|
||||
errors::Code::Unsupported,
|
||||
"Ticket data API unavailable",
|
||||
))
|
||||
}
|
||||
|
||||
async fn authorize(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
operation: Operation<'_>,
|
||||
) -> Result<(), errors::HostError>;
|
||||
async fn open(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
slot: &str,
|
||||
) -> Result<Arc<dyn Any + Send + Sync>, errors::HostError>;
|
||||
async fn send(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
binding: &(dyn Any + Send + Sync),
|
||||
request: http::Request,
|
||||
) -> Result<http::Response, errors::HostError>;
|
||||
async fn log(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
entry: logging::Entry,
|
||||
) -> Result<(), errors::HostError>;
|
||||
}
|
||||
|
||||
pub(crate) fn error(code: errors::Code, message: &str) -> errors::HostError {
|
||||
errors::HostError {
|
||||
code,
|
||||
message: message.into(),
|
||||
diagnostic_id: String::new(),
|
||||
effect: errors::EffectObservation::NotDispatched,
|
||||
}
|
||||
}
|
||||
|
||||
/// Portable provider boundary shared by Remote and Embedded owners.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TicketDataOperation {
|
||||
Get(server_api::PluginDataAddress),
|
||||
Mutate {
|
||||
address: server_api::PluginDataAddress,
|
||||
operation_key: String,
|
||||
mutation: server_api::PluginDataMutation,
|
||||
},
|
||||
List {
|
||||
collection: server_api::PluginDataCollection,
|
||||
cursor: Option<String>,
|
||||
limit: u32,
|
||||
},
|
||||
LookupMutation {
|
||||
address: server_api::PluginDataAddress,
|
||||
operation_key: String,
|
||||
},
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub enum TicketDataResult {
|
||||
Cell(server_api::PluginDataCell),
|
||||
Mutated(server_api::PluginDataReceipt),
|
||||
Listed(server_api::PluginDataPage),
|
||||
MutationLookup(server_api::PluginDataMutationLookup),
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
pub mod bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
path: "../../resources/plugin/wit",
|
||||
world: "https-logged-tool-plugin",
|
||||
imports: { default: async | trappable },
|
||||
exports: { default: async },
|
||||
with: { "yoi:host/connections/connection": crate::host::Connection },
|
||||
});
|
||||
}
|
||||
|
||||
pub mod delivery;
|
||||
mod executor;
|
||||
pub mod host;
|
||||
pub mod owner;
|
||||
mod sandbox;
|
||||
pub use executor::*;
|
||||
|
||||
/// Additive Ticket imports share the original v2 error types.
|
||||
pub mod ticket_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
path: "../../resources/plugin/wit-ticket",
|
||||
world: "ticket-tool-plugin",
|
||||
imports: { default: async | trappable },
|
||||
exports: { default: async },
|
||||
with: {
|
||||
"yoi:host/errors@2.0.0": crate::bindings::yoi::host::errors,
|
||||
"yoi:host/objects@2.1.0/ticket": crate::host::Ticket,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/// Service exports reuse the existing Host resources and their exact import ABI.
|
||||
pub mod service_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
path: "../../resources/plugin/wit-service",
|
||||
world: "ticket-service-plugin",
|
||||
imports: { default: async | trappable },
|
||||
exports: { default: async },
|
||||
with: {
|
||||
"yoi:host/errors@2.0.0": crate::bindings::yoi::host::errors,
|
||||
"yoi:host/connections@2.0.0": crate::bindings::yoi::host::connections,
|
||||
"yoi:host/http@2.0.0": crate::bindings::yoi::host::http,
|
||||
"yoi:host/logging@2.0.0": crate::bindings::yoi::host::logging,
|
||||
"yoi:host/objects@2.1.0": crate::host::objects,
|
||||
"yoi:host/ticket-read@2.1.0": crate::host::ticket_read,
|
||||
"yoi:host/extension-data@2.1.0": crate::host::extension_data,
|
||||
},
|
||||
});
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
//! Invocation-local resource identity is independent of owner Ticket selectors.
|
||||
use super::*;
|
||||
use server_api::*;
|
||||
|
||||
struct OwnerTicket {
|
||||
invocation_id: String,
|
||||
ticket_id: String,
|
||||
writable: bool,
|
||||
identity: Arc<()>,
|
||||
}
|
||||
fn denied() -> PluginHostError {
|
||||
local_error(
|
||||
PluginHostErrorCode::Denied,
|
||||
PluginEffectObservation::NotDispatched,
|
||||
)
|
||||
}
|
||||
fn invalid() -> PluginHostError {
|
||||
local_error(
|
||||
PluginHostErrorCode::InvalidInput,
|
||||
PluginEffectObservation::NotDispatched,
|
||||
)
|
||||
}
|
||||
impl OwnerHost {
|
||||
fn ticket<'a>(
|
||||
&self,
|
||||
binding: &'a (dyn Any + Send + Sync),
|
||||
) -> Result<&'a OwnerTicket, PluginHostError> {
|
||||
binding
|
||||
.downcast_ref::<OwnerTicket>()
|
||||
.filter(|t| {
|
||||
t.invocation_id == self.context.invocation_id
|
||||
&& Arc::ptr_eq(&t.identity, &self.binding_identity)
|
||||
})
|
||||
.ok_or_else(denied)
|
||||
}
|
||||
pub(super) async fn resolve_ticket_binding(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
ticket_ref: &str,
|
||||
access: objects::Access,
|
||||
) -> Result<Arc<dyn Any + Send + Sync>, PluginHostError> {
|
||||
self.check_context(context)?;
|
||||
if ticket_ref.is_empty() || ticket_ref.len() > 256 {
|
||||
return Err(invalid());
|
||||
}
|
||||
let writable = matches!(access, objects::Access::ExtensionWrite);
|
||||
if if writable {
|
||||
context.grants.ticket_data_write.is_empty()
|
||||
} else {
|
||||
!context.grants.ticket_read && context.grants.ticket_data_read.is_empty()
|
||||
} {
|
||||
return Err(denied());
|
||||
}
|
||||
match call_owner(
|
||||
self.owner.as_ref(),
|
||||
context,
|
||||
PluginHostRequest::ResolveTicket {
|
||||
invocation_id: context.invocation_id.clone(),
|
||||
ticket_ref: ticket_ref.into(),
|
||||
access: if writable {
|
||||
PluginTicketAccess::ExtensionWrite
|
||||
} else {
|
||||
PluginTicketAccess::Read
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
PluginHostResponse::TicketResolved { ticket_id }
|
||||
if !ticket_id.is_empty() && ticket_id.len() <= 256 =>
|
||||
{
|
||||
Ok(Arc::new(OwnerTicket {
|
||||
invocation_id: context.invocation_id.clone(),
|
||||
ticket_id,
|
||||
writable,
|
||||
identity: self.binding_identity.clone(),
|
||||
}))
|
||||
}
|
||||
_ => Err(protocol_error(false)),
|
||||
}
|
||||
}
|
||||
pub(super) async fn read_ticket_binding(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
binding: &(dyn Any + Send + Sync),
|
||||
) -> Result<ticket_read::Snapshot, PluginHostError> {
|
||||
self.check_context(context)?;
|
||||
let ticket = self.ticket(binding)?;
|
||||
if !context.grants.ticket_read {
|
||||
return Err(denied());
|
||||
}
|
||||
match call_owner(
|
||||
self.owner.as_ref(),
|
||||
context,
|
||||
PluginHostRequest::ReadTicket {
|
||||
invocation_id: context.invocation_id.clone(),
|
||||
ticket_id: ticket.ticket_id.clone(),
|
||||
},
|
||||
)
|
||||
.await?
|
||||
{
|
||||
PluginHostResponse::TicketRead { snapshot }
|
||||
if snapshot.body.len() <= PLUGIN_TICKET_BODY_LIMIT =>
|
||||
{
|
||||
Ok(ticket_read::Snapshot {
|
||||
title: snapshot.title,
|
||||
state: match snapshot.case_state {
|
||||
PluginTicketCaseState::Open => ticket_read::CaseState::Open,
|
||||
PluginTicketCaseState::Closed => ticket_read::CaseState::Closed,
|
||||
},
|
||||
content_digest: snapshot.content_digest,
|
||||
body: snapshot.body,
|
||||
truncated: snapshot.truncated,
|
||||
})
|
||||
}
|
||||
_ => Err(protocol_error(false)),
|
||||
}
|
||||
}
|
||||
pub(super) async fn ticket_data_binding(
|
||||
&self,
|
||||
context: &InvocationContext,
|
||||
binding: &(dyn Any + Send + Sync),
|
||||
operation: TicketDataOperation,
|
||||
) -> Result<TicketDataResult, PluginHostError> {
|
||||
self.check_context(context)?;
|
||||
let ticket = self.ticket(binding)?;
|
||||
let (collection, write) = match &operation {
|
||||
TicketDataOperation::Get(a)
|
||||
| TicketDataOperation::LookupMutation { address: a, .. } => (&a.collection, false),
|
||||
TicketDataOperation::Mutate { address: a, .. } => (&a.collection, true),
|
||||
TicketDataOperation::List { collection, .. } => (collection, false),
|
||||
};
|
||||
let identity = (
|
||||
collection.collection.clone(),
|
||||
collection.schema_id.clone(),
|
||||
collection.schema_version.clone(),
|
||||
);
|
||||
let lookup = matches!(&operation, TicketDataOperation::LookupMutation { .. });
|
||||
let grants = if write || lookup {
|
||||
&context.grants.ticket_data_write
|
||||
} else {
|
||||
&context.grants.ticket_data_read
|
||||
};
|
||||
if !grants.contains(&identity) || ((write || lookup) && !ticket.writable) {
|
||||
return Err(denied());
|
||||
}
|
||||
validate_operation(&operation)?;
|
||||
let invocation_id = context.invocation_id.clone();
|
||||
let ticket_id = ticket.ticket_id.clone();
|
||||
let mutation = matches!(&operation, TicketDataOperation::Mutate { .. });
|
||||
let request = match operation {
|
||||
TicketDataOperation::Get(address) => PluginHostRequest::DataGet {
|
||||
invocation_id,
|
||||
ticket_id,
|
||||
address,
|
||||
},
|
||||
TicketDataOperation::Mutate {
|
||||
address,
|
||||
operation_key,
|
||||
mutation,
|
||||
} => PluginHostRequest::DataMutate {
|
||||
invocation_id,
|
||||
ticket_id,
|
||||
address,
|
||||
operation_key,
|
||||
mutation,
|
||||
},
|
||||
TicketDataOperation::List {
|
||||
collection,
|
||||
cursor,
|
||||
limit,
|
||||
} => PluginHostRequest::DataList {
|
||||
invocation_id,
|
||||
ticket_id,
|
||||
collection,
|
||||
cursor,
|
||||
limit,
|
||||
},
|
||||
TicketDataOperation::LookupMutation {
|
||||
address,
|
||||
operation_key,
|
||||
} => PluginHostRequest::DataLookupMutation {
|
||||
invocation_id,
|
||||
ticket_id,
|
||||
address,
|
||||
operation_key,
|
||||
},
|
||||
};
|
||||
match call_owner(self.owner.as_ref(), context, request.clone()).await? {
|
||||
PluginHostResponse::DataCell { cell }
|
||||
if matches!(request, PluginHostRequest::DataGet { .. }) =>
|
||||
{
|
||||
Ok(TicketDataResult::Cell(cell))
|
||||
}
|
||||
PluginHostResponse::DataMutated { receipt } if mutation => {
|
||||
Ok(TicketDataResult::Mutated(receipt))
|
||||
}
|
||||
PluginHostResponse::DataListed { page }
|
||||
if matches!(request, PluginHostRequest::DataList { .. }) =>
|
||||
{
|
||||
Ok(TicketDataResult::Listed(page))
|
||||
}
|
||||
PluginHostResponse::DataMutationLookup { outcome }
|
||||
if matches!(request, PluginHostRequest::DataLookupMutation { .. }) =>
|
||||
{
|
||||
Ok(TicketDataResult::MutationLookup(outcome))
|
||||
}
|
||||
_ => Err(protocol_error(mutation)),
|
||||
}
|
||||
}
|
||||
}
|
||||
fn text(s: &str, limit: usize) -> bool {
|
||||
!s.is_empty() && s.len() <= limit && !s.chars().any(char::is_control)
|
||||
}
|
||||
pub(crate) fn validate_operation(operation: &TicketDataOperation) -> Result<(), PluginHostError> {
|
||||
let collection = match operation {
|
||||
TicketDataOperation::Get(a)
|
||||
| TicketDataOperation::Mutate { address: a, .. }
|
||||
| TicketDataOperation::LookupMutation { address: a, .. } => {
|
||||
if !text(&a.key, 256) {
|
||||
return Err(invalid());
|
||||
}
|
||||
&a.collection
|
||||
}
|
||||
TicketDataOperation::List {
|
||||
collection,
|
||||
cursor,
|
||||
limit,
|
||||
} => {
|
||||
if *limit == 0
|
||||
|| *limit > PLUGIN_DATA_LIST_LIMIT
|
||||
|| cursor.as_ref().is_some_and(|c| !text(c, 4096))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
collection
|
||||
}
|
||||
};
|
||||
if [
|
||||
&collection.collection,
|
||||
&collection.schema_id,
|
||||
&collection.schema_version,
|
||||
]
|
||||
.iter()
|
||||
.any(|s| !text(s, 128))
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
match operation {
|
||||
TicketDataOperation::Mutate {
|
||||
operation_key,
|
||||
mutation,
|
||||
..
|
||||
} => {
|
||||
if !text(operation_key, 256) {
|
||||
return Err(invalid());
|
||||
}
|
||||
match mutation {
|
||||
PluginDataMutation::Put {
|
||||
expected,
|
||||
value_json,
|
||||
} => {
|
||||
if value_json.len() > PLUGIN_DATA_VALUE_LIMIT
|
||||
|| serde_json::from_str::<serde_json::Value>(value_json).is_err()
|
||||
{
|
||||
return Err(invalid());
|
||||
}
|
||||
if let PluginDataExpectation::LastWrite(id) = expected {
|
||||
if !text(id, 256) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
PluginDataMutation::Delete {
|
||||
expected_last_write,
|
||||
} => {
|
||||
if !text(expected_last_write, 256) {
|
||||
return Err(invalid());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
TicketDataOperation::LookupMutation { operation_key, .. } if !text(operation_key, 256) => {
|
||||
return Err(invalid());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
//! Aggregate Store budgets: Wasmtime's built-in memory/table sizes are per
|
||||
//! object, so count accepted growth across all objects as well. Nothing in a
|
||||
//! Tool Store outlives that invocation; no cross-Store accounting is retained.
|
||||
use crate::Limits;
|
||||
use wasmtime::{ResourceLimiter, StoreLimits, StoreLimitsBuilder};
|
||||
|
||||
/// Additional execution-policy check, not a replacement ABI/package validator.
|
||||
/// Wasmtime's Store limiter does not meter Component-internal resource tables.
|
||||
/// The Tool ABI needs only imported Host resources, whose creation is metered
|
||||
/// by our adapter; guest-defined resources and allocators therefore fail closed.
|
||||
/// `parse_all` visits concrete nested Components as well as the outer one.
|
||||
pub(crate) fn check_component_resources(bytes: &[u8]) -> wasmtime::Result<()> {
|
||||
use wasmparser::{CanonicalFunction, ComponentType, Parser, Payload};
|
||||
for payload in Parser::new(0).parse_all(bytes) {
|
||||
match payload? {
|
||||
Payload::ComponentTypeSection(types) => {
|
||||
for ty in types {
|
||||
if matches!(ty?, ComponentType::Resource { .. }) {
|
||||
return Err(wasmtime::Error::msg(
|
||||
"guest-defined Component resources are unsupported by the Tool sandbox",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Payload::ComponentCanonicalSection(functions) => {
|
||||
for function in functions {
|
||||
if matches!(function?, CanonicalFunction::ResourceNew { .. }) {
|
||||
return Err(wasmtime::Error::msg(
|
||||
"guest Component resource.new is unsupported by the Tool sandbox",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) struct SandboxLimits {
|
||||
inner: StoreLimits,
|
||||
memory: Budget,
|
||||
table: Budget,
|
||||
}
|
||||
struct Budget {
|
||||
used: usize,
|
||||
last_growth: usize,
|
||||
ceiling: usize,
|
||||
}
|
||||
impl Budget {
|
||||
fn new(ceiling: usize) -> Self {
|
||||
Self {
|
||||
used: 0,
|
||||
last_growth: 0,
|
||||
ceiling,
|
||||
}
|
||||
}
|
||||
fn reserve(&mut self, current: usize, desired: usize, kind: &str) -> wasmtime::Result<()> {
|
||||
let growth = desired.saturating_sub(current);
|
||||
if growth > self.ceiling.saturating_sub(self.used) {
|
||||
return Err(wasmtime::Error::msg(format!(
|
||||
"aggregate {kind} budget exceeded"
|
||||
)));
|
||||
}
|
||||
self.used += growth;
|
||||
self.last_growth = growth;
|
||||
Ok(())
|
||||
}
|
||||
fn failed(&mut self) {
|
||||
self.used -= self.last_growth;
|
||||
self.last_growth = 0;
|
||||
}
|
||||
}
|
||||
impl SandboxLimits {
|
||||
pub(crate) fn new(limits: &Limits) -> Self {
|
||||
Self {
|
||||
inner: StoreLimitsBuilder::new()
|
||||
.memory_size(limits.memory_bytes)
|
||||
.table_elements(limits.table_elements)
|
||||
.instances(limits.instances)
|
||||
.memories(limits.memories)
|
||||
.tables(limits.tables)
|
||||
.trap_on_grow_failure(true)
|
||||
.build(),
|
||||
memory: Budget::new(limits.memory_bytes),
|
||||
table: Budget::new(limits.table_elements),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ResourceLimiter for SandboxLimits {
|
||||
fn memory_growing(
|
||||
&mut self,
|
||||
current: usize,
|
||||
desired: usize,
|
||||
maximum: Option<usize>,
|
||||
) -> wasmtime::Result<bool> {
|
||||
if !self.inner.memory_growing(current, desired, maximum)? {
|
||||
return Ok(false);
|
||||
}
|
||||
self.memory.reserve(current, desired, "memory")?;
|
||||
Ok(true)
|
||||
}
|
||||
fn table_growing(
|
||||
&mut self,
|
||||
current: usize,
|
||||
desired: usize,
|
||||
maximum: Option<usize>,
|
||||
) -> wasmtime::Result<bool> {
|
||||
if !self.inner.table_growing(current, desired, maximum)? {
|
||||
return Ok(false);
|
||||
}
|
||||
self.table.reserve(current, desired, "table")?;
|
||||
Ok(true)
|
||||
}
|
||||
fn memory_grow_failed(&mut self, error: wasmtime::Error) -> wasmtime::Result<()> {
|
||||
self.memory.failed();
|
||||
self.inner.memory_grow_failed(error)
|
||||
}
|
||||
fn table_grow_failed(&mut self, error: wasmtime::Error) -> wasmtime::Result<()> {
|
||||
self.table.failed();
|
||||
self.inner.table_grow_failed(error)
|
||||
}
|
||||
fn instances(&self) -> usize {
|
||||
self.inner.instances()
|
||||
}
|
||||
fn memories(&self) -> usize {
|
||||
self.inner.memories()
|
||||
}
|
||||
fn tables(&self) -> usize {
|
||||
self.inner.tables()
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+367
@@ -0,0 +1,367 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "executor-test-guest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"yoi-plugin-pdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn 2.0.119",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoi-plugin-pdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "executor-test-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
yoi-plugin-pdk = { path = "../../../plugin-pdk" }
|
||||
@@ -0,0 +1,138 @@
|
||||
use yoi_plugin_pdk::wit_bindgen;
|
||||
wit_bindgen::generate!({
|
||||
path: "../../../../resources/plugin/wit",
|
||||
world: "https-logged-tool-plugin",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
use exports::yoi::plugin::tool::{Guest, Invocation, Output, ToolError};
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use yoi::host::{connections, http, logging};
|
||||
|
||||
static CALLS: AtomicU32 = AtomicU32::new(0);
|
||||
struct Plugin;
|
||||
impl Guest for Plugin {
|
||||
fn invoke(input: Invocation) -> Result<Output, ToolError> {
|
||||
let count = CALLS.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
let mode = input.input_json.trim_matches('"');
|
||||
match mode {
|
||||
"trap" => panic!("guest trap"),
|
||||
"loop" => loop {
|
||||
std::hint::black_box(CALLS.load(Ordering::SeqCst));
|
||||
},
|
||||
"wall-loop" => {
|
||||
logging::log(&logging::Entry {
|
||||
level: logging::Level::Info,
|
||||
event_code: "count".into(),
|
||||
message: "loop entered".into(),
|
||||
fields: vec![],
|
||||
})
|
||||
.map_err(failure)?;
|
||||
loop {
|
||||
std::hint::black_box(CALLS.load(Ordering::SeqCst));
|
||||
}
|
||||
}
|
||||
"memory" => {
|
||||
core::arch::wasm32::memory_grow::<0>(8192);
|
||||
}
|
||||
"error-output" => {
|
||||
return Err(ToolError {
|
||||
code: "typed".into(),
|
||||
message: "\"".repeat(40_000),
|
||||
});
|
||||
}
|
||||
"typed" | "unknown" => {
|
||||
return Err(ToolError {
|
||||
code: mode.into(),
|
||||
message: "bounded business failure".into(),
|
||||
});
|
||||
}
|
||||
"logging" => {
|
||||
for _ in 0..3 {
|
||||
logging::log(&logging::Entry {
|
||||
level: logging::Level::Info,
|
||||
event_code: "count".into(),
|
||||
message: "safe diagnostic".into(),
|
||||
fields: vec![logging::Field {
|
||||
name: "calls".into(),
|
||||
value: logging::Scalar::Unsigned(count as u64),
|
||||
}],
|
||||
})
|
||||
.map_err(failure)?;
|
||||
}
|
||||
}
|
||||
"http" | "stale" | "foreign" | "resources" => {
|
||||
if mode == "resources" {
|
||||
let _resources: Vec<_> =
|
||||
(0..64).map(|_| connections::open("api").unwrap()).collect();
|
||||
}
|
||||
let target = if mode == "foreign" {
|
||||
// An attacker can fabricate a representation, not a capability.
|
||||
unsafe { connections::Connection::from_handle(0) }
|
||||
} else {
|
||||
connections::open("api").map_err(failure)?
|
||||
};
|
||||
let target = if mode == "stale" {
|
||||
let handle = target.handle();
|
||||
drop(target);
|
||||
unsafe { connections::Connection::from_handle(handle) }
|
||||
} else {
|
||||
target
|
||||
};
|
||||
let response = http::send(
|
||||
&target,
|
||||
&http::Request {
|
||||
method: http::Method::Get,
|
||||
path: "/status".into(),
|
||||
query: vec![],
|
||||
headers: vec![],
|
||||
body: vec![],
|
||||
timeout_ms: 15_000,
|
||||
effect_key: "one".into(),
|
||||
},
|
||||
)
|
||||
.map_err(failure)?;
|
||||
return Ok(Output {
|
||||
summary: String::from_utf8(response.body).unwrap(),
|
||||
content: None,
|
||||
structured_json: Some(format!("{{\"calls\":{count}}}")),
|
||||
});
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
Ok(Output {
|
||||
summary: if mode == "output" {
|
||||
"x".repeat(70_000)
|
||||
} else {
|
||||
input.invocation_id
|
||||
},
|
||||
content: None,
|
||||
structured_json: Some(if mode == "bad-json" {
|
||||
"not-json".into()
|
||||
} else if mode == "bad-schema" {
|
||||
"{}".into()
|
||||
} else {
|
||||
format!("{{\"calls\":{count}}}")
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
fn failure(error: yoi::host::errors::HostError) -> ToolError {
|
||||
ToolError {
|
||||
code: "host".into(),
|
||||
message: format!(
|
||||
"{}:{}",
|
||||
match error.code {
|
||||
yoi::host::errors::Code::DeadlineExceeded => "deadline-exceeded",
|
||||
yoi::host::errors::Code::QuotaExceeded => "quota-exceeded",
|
||||
_ => "host-rejected",
|
||||
},
|
||||
match error.effect {
|
||||
yoi::host::errors::EffectObservation::NotDispatched => "not-dispatched",
|
||||
yoi::host::errors::EffectObservation::ResponseObserved => "response-observed",
|
||||
yoi::host::errors::EffectObservation::Indeterminate => "indeterminate",
|
||||
}
|
||||
),
|
||||
}
|
||||
}
|
||||
export!(Plugin);
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "service-test-guest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"yoi-plugin-pdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn 2.0.119",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoi-plugin-pdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "service-test-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
yoi-plugin-pdk = { path = "../../../plugin-pdk" }
|
||||
@@ -0,0 +1,47 @@
|
||||
schema_version = 2
|
||||
id = "service-test"
|
||||
name = "Service test"
|
||||
version = "1.0.0"
|
||||
surfaces = ["service"]
|
||||
config_schema = "{ mode = String }"
|
||||
|
||||
[runtime]
|
||||
kind = "wasm-component"
|
||||
component = "plugin.component.wasm"
|
||||
world = "yoi:plugin/ticket-service-plugin@2.2.0"
|
||||
required_imports = ["yoi:host/errors@2.0.0", "yoi:host/connections@2.0.0", "yoi:host/http@2.0.0", "yoi:host/logging@2.0.0", "yoi:host/objects@2.1.0", "yoi:host/ticket-read@2.1.0", "yoi:host/extension-data@2.1.0"]
|
||||
|
||||
[[services]]
|
||||
name = "ticket_events"
|
||||
subscriptions = ["ticket_changed", "ticket_deleted"]
|
||||
|
||||
[[connections]]
|
||||
name = "api"
|
||||
methods = ["POST"]
|
||||
path_prefixes = ["/events"]
|
||||
|
||||
[[data_collections]]
|
||||
collection = "notes"
|
||||
schema_id = "note"
|
||||
schema_version = "1"
|
||||
schema = { type = "string", maxLength = 64 }
|
||||
|
||||
[[requested_grants]]
|
||||
kind = "logging"
|
||||
[[requested_grants]]
|
||||
kind = "https"
|
||||
slot = "api"
|
||||
[[requested_grants]]
|
||||
kind = "ticket_events"
|
||||
[[requested_grants]]
|
||||
kind = "ticket_read"
|
||||
[[requested_grants]]
|
||||
kind = "ticket_data_read"
|
||||
collection = "notes"
|
||||
schema_id = "note"
|
||||
schema_version = "1"
|
||||
[[requested_grants]]
|
||||
kind = "ticket_data_write"
|
||||
collection = "notes"
|
||||
schema_id = "note"
|
||||
schema_version = "1"
|
||||
@@ -0,0 +1,135 @@
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
use yoi_plugin_pdk::{ToolError, wit_bindgen};
|
||||
wit_bindgen::generate!({
|
||||
path: "../../../../resources/plugin/wit-service",
|
||||
world: "ticket-service-plugin",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
use exports::yoi::plugin::service::{Envelope, EventResult, TicketEventType};
|
||||
use yoi::host2_0_0::{connections, errors, http, logging};
|
||||
use yoi::host2_1_0::{extension_data as data, objects, ticket_read};
|
||||
|
||||
static CALLS: AtomicU32 = AtomicU32::new(0);
|
||||
fn fresh() {
|
||||
assert_eq!(CALLS.fetch_add(1, Ordering::SeqCst), 0);
|
||||
}
|
||||
fn failure(e: errors::HostError) -> ToolError {
|
||||
ToolError::failed(format!("{:?}", e.code))
|
||||
}
|
||||
fn address() -> data::Address {
|
||||
data::Address {
|
||||
namespace: data::Collection {
|
||||
collection: "notes".into(),
|
||||
schema_id: "note".into(),
|
||||
schema_version: "1".into(),
|
||||
},
|
||||
key: "main".into(),
|
||||
}
|
||||
}
|
||||
fn request() -> http::Request {
|
||||
http::Request {
|
||||
method: http::Method::Post,
|
||||
path: "/events".into(),
|
||||
query: vec![],
|
||||
headers: vec![],
|
||||
body: b"event".to_vec(),
|
||||
timeout_ms: 15_000,
|
||||
effect_key: "event-hook".into(),
|
||||
}
|
||||
}
|
||||
fn lifecycle(mode: String) -> yoi_plugin_pdk::Result<()> {
|
||||
fresh();
|
||||
match mode.as_str() {
|
||||
"open" => {
|
||||
connections::open("api").map_err(failure)?;
|
||||
}
|
||||
"resolve" => {
|
||||
objects::resolve_ticket("T-796", objects::Access::Read).map_err(failure)?;
|
||||
}
|
||||
"read" => {
|
||||
let t = unsafe { objects::Ticket::from_handle(0) };
|
||||
ticket_read::read(&t).map_err(failure)?;
|
||||
}
|
||||
"data" => {
|
||||
let t = unsafe { objects::Ticket::from_handle(0) };
|
||||
data::get(&t, &address()).map_err(failure)?;
|
||||
}
|
||||
"http" => {
|
||||
let c = unsafe { connections::Connection::from_handle(0) };
|
||||
http::send(&c, &request()).map_err(failure)?;
|
||||
}
|
||||
"loop" => loop {
|
||||
std::hint::spin_loop();
|
||||
},
|
||||
_ => {
|
||||
logging::log(&logging::Entry {
|
||||
level: logging::Level::Info,
|
||||
event_code: "lifecycle".into(),
|
||||
message: "configured".into(),
|
||||
fields: vec![],
|
||||
})
|
||||
.map_err(failure)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn start(config: String) -> yoi_plugin_pdk::Result<()> {
|
||||
let config: std::collections::BTreeMap<String, String> =
|
||||
yoi_plugin_pdk::parse_json_input(&config)?;
|
||||
lifecycle(config.get("mode").cloned().unwrap_or_default())
|
||||
}
|
||||
fn stop(reason: String) -> yoi_plugin_pdk::Result<()> {
|
||||
lifecycle(reason)
|
||||
}
|
||||
fn work(input: &Envelope) -> yoi_plugin_pdk::Result<()> {
|
||||
if matches!(input.event_type, TicketEventType::TicketDeleted) {
|
||||
return Ok(());
|
||||
}
|
||||
let ticket = objects::resolve_ticket(&input.ticket_ref, objects::Access::ExtensionWrite)
|
||||
.map_err(failure)?;
|
||||
let snapshot = ticket_read::read(&ticket).map_err(failure)?;
|
||||
if input.config_json == r#"{"mode":"abi-probe"}"# {
|
||||
data::list(&ticket, &address().namespace, None, 1).map_err(failure)?;
|
||||
data::delete(&ticket, &address(), "observed", "delete").map_err(failure)?;
|
||||
}
|
||||
// A saved mutation is checked before reading the current cell or rebuilding
|
||||
// its CAS request. Failed lookup is never interpreted as not-recorded.
|
||||
match data::lookup_mutation(&ticket, &address(), "event-note").map_err(failure)? {
|
||||
data::MutationLookup::Committed(_) => (),
|
||||
data::MutationLookup::Expired => return Err(ToolError::failed("expired")),
|
||||
data::MutationLookup::NotRecorded => {
|
||||
let expected = match data::get(&ticket, &address()).map_err(failure)? {
|
||||
data::Cell::NeverWritten => data::Expectation::NeverWritten,
|
||||
data::Cell::Deleted(last) => data::Expectation::LastWrite(last),
|
||||
data::Cell::Present(cell) => data::Expectation::LastWrite(cell.last_write),
|
||||
};
|
||||
let value = yoi_plugin_pdk::extension_data_json(&snapshot.title)?;
|
||||
data::put(&ticket, &address(), &expected, "event-note", &value).map_err(failure)?;
|
||||
}
|
||||
}
|
||||
let connection = connections::open("api").map_err(failure)?;
|
||||
// Repeating this exact request uses the Host receipt, not a new effect key.
|
||||
http::send(&connection, &request()).map_err(failure)?;
|
||||
Ok(())
|
||||
}
|
||||
fn event(input: Envelope) -> EventResult {
|
||||
fresh();
|
||||
if input.config_json == r#"{"mode":"loop"}"# {
|
||||
loop {
|
||||
std::hint::spin_loop();
|
||||
}
|
||||
}
|
||||
if input.config_json == r#"{"mode":"reject"}"# {
|
||||
return EventResult::Reject("rejected".into());
|
||||
}
|
||||
if input.config_json == r#"{"mode":"oversize-retry"}"# {
|
||||
return EventResult::RetryAfter(300_001);
|
||||
}
|
||||
if work(&input).is_err() && input.config_json != r#"{"mode":"swallow-unknown"}"# {
|
||||
EventResult::RetryAfter(10)
|
||||
} else {
|
||||
EventResult::Ack
|
||||
}
|
||||
}
|
||||
yoi_plugin_pdk::export_component_service!(ServiceGuest, start, event, stop);
|
||||
@@ -0,0 +1,877 @@
|
||||
//! Authored Rust -> PDK -> validated Component -> fresh Wasmtime Store -> OwnerHost.
|
||||
//! The deterministic owner replaces storage/network, not the execution adapter;
|
||||
//! transaction/lease/live policy guarantees are tested by the domain owner suites.
|
||||
use async_trait::async_trait;
|
||||
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
|
||||
use server_api::*;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::Path,
|
||||
process::Command,
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::time::Instant;
|
||||
use yoi_plugin_runtime::{
|
||||
host::{CancellationToken, Grants, InvocationContext},
|
||||
owner::{OwnerHost, PluginHostOwner},
|
||||
*,
|
||||
};
|
||||
|
||||
fn component() -> &'static [u8] {
|
||||
static COMPONENT: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
COMPONENT.get_or_init(|| {
|
||||
let target =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/plugin-service-fixture/guest");
|
||||
let output = Command::new(env!("CARGO"))
|
||||
.args([
|
||||
"build",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"--release",
|
||||
"--target",
|
||||
"wasm32-unknown-unknown",
|
||||
"--manifest-path",
|
||||
])
|
||||
.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/service-guest/Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(&target)
|
||||
.output()
|
||||
.unwrap();
|
||||
fs::create_dir_all(target.parent().unwrap()).unwrap();
|
||||
fs::write(
|
||||
target.parent().unwrap().join("guest-build.log"),
|
||||
&output.stderr,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Service fixture requires wasm32 std + lld on PATH: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let core = fs::read(target.join("wasm32-unknown-unknown/release/service_test_guest.wasm"))
|
||||
.unwrap();
|
||||
let bytes = wit_component::ComponentEncoder::default()
|
||||
.module(&core)
|
||||
.unwrap()
|
||||
.validate(true)
|
||||
.encode()
|
||||
.unwrap();
|
||||
fs::write(
|
||||
target.parent().unwrap().join("service.component.wasm"),
|
||||
&bytes,
|
||||
)
|
||||
.unwrap();
|
||||
bytes
|
||||
})
|
||||
}
|
||||
const MANIFEST: &str = include_str!("service-guest/plugin.toml");
|
||||
fn package(manifest: &str) -> Result<plugin::CanonicalPluginArtifact, plugin::PluginDiagnostic> {
|
||||
package_bytes(manifest, component())
|
||||
}
|
||||
fn package_bytes(
|
||||
manifest: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<plugin::CanonicalPluginArtifact, plugin::PluginDiagnostic> {
|
||||
let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/plugin-service-fixture");
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
let dir = tempfile::tempdir_in(target).unwrap();
|
||||
fs::write(dir.path().join("plugin.toml"), manifest).unwrap();
|
||||
fs::write(dir.path().join("plugin.component.wasm"), bytes).unwrap();
|
||||
let materialized = plugin::read_plugin_directory(
|
||||
dir.path(),
|
||||
PluginSourceKind::Explicit,
|
||||
&PluginPackageLimits::default(),
|
||||
)?;
|
||||
plugin::pack_plugin_package(&materialized, &PluginPackageLimits::default())
|
||||
}
|
||||
fn compile(ex: &Executor) -> CompiledService {
|
||||
let p = package(MANIFEST).unwrap();
|
||||
ex.compile_service(
|
||||
&p.bytes,
|
||||
&p.package_digest,
|
||||
&p.artifact_digest,
|
||||
p.artifact_size,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
#[derive(Clone, Copy, Default)]
|
||||
enum Fault {
|
||||
#[default]
|
||||
None,
|
||||
HttpUnknown,
|
||||
DataUnknown,
|
||||
HttpWait,
|
||||
DataWait,
|
||||
LookupUnavailable,
|
||||
LookupExpired,
|
||||
ReadDenied,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
calls: Vec<PluginHostRequest>,
|
||||
writes: usize,
|
||||
sends: usize,
|
||||
receipts: BTreeMap<String, PluginDataReceipt>,
|
||||
http: BTreeMap<String, PluginHttpResponse>,
|
||||
}
|
||||
struct Owner {
|
||||
state: Mutex<State>,
|
||||
fault: Fault,
|
||||
waiting: tokio::sync::Notify,
|
||||
}
|
||||
impl Owner {
|
||||
fn new(fault: Fault) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
state: Mutex::new(State::default()),
|
||||
fault,
|
||||
waiting: tokio::sync::Notify::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
fn rejected(code: PluginHostErrorCode, effect: PluginEffectObservation) -> PluginHostError {
|
||||
PluginHostError {
|
||||
code,
|
||||
effect,
|
||||
diagnostic_id: "test".into(),
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl PluginHostOwner for Owner {
|
||||
async fn call(
|
||||
&self,
|
||||
request: PluginHostRequest,
|
||||
) -> Result<PluginHostResponse, PluginHostError> {
|
||||
if (matches!(self.fault, Fault::HttpWait)
|
||||
&& matches!(request, PluginHostRequest::Send { .. }))
|
||||
|| (matches!(self.fault, Fault::DataWait)
|
||||
&& matches!(request, PluginHostRequest::DataMutate { .. }))
|
||||
{
|
||||
self.waiting.notify_one();
|
||||
return std::future::pending().await;
|
||||
}
|
||||
let mut s = self.state.lock().unwrap();
|
||||
s.calls.push(request.clone());
|
||||
Ok(match request {
|
||||
PluginHostRequest::Begin { .. } => PluginHostResponse::Invocation {
|
||||
invocation_id: "stable-service-invocation".into(),
|
||||
expires_at_ms: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64
|
||||
+ 60_000,
|
||||
},
|
||||
PluginHostRequest::Authorize { .. } => PluginHostResponse::Authorized,
|
||||
PluginHostRequest::Log { .. } => PluginHostResponse::Logged,
|
||||
PluginHostRequest::ResolveTicket { ticket_ref, .. } => {
|
||||
assert_eq!(ticket_ref, "T-796");
|
||||
PluginHostResponse::TicketResolved {
|
||||
ticket_id: "canonical-ticket".into(),
|
||||
}
|
||||
}
|
||||
PluginHostRequest::ReadTicket { .. } => {
|
||||
if matches!(self.fault, Fault::ReadDenied) {
|
||||
return Err(rejected(
|
||||
PluginHostErrorCode::Denied,
|
||||
PluginEffectObservation::NotDispatched,
|
||||
));
|
||||
}
|
||||
PluginHostResponse::TicketRead {
|
||||
snapshot: PluginTicketSnapshot {
|
||||
title: "note".into(),
|
||||
case_state: PluginTicketCaseState::Open,
|
||||
content_digest: "digest".into(),
|
||||
body: "body".into(),
|
||||
truncated: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
PluginHostRequest::DataLookupMutation { operation_key, .. } => {
|
||||
if matches!(self.fault, Fault::LookupUnavailable) {
|
||||
return Err(rejected(
|
||||
PluginHostErrorCode::Unavailable,
|
||||
PluginEffectObservation::NotDispatched,
|
||||
));
|
||||
}
|
||||
PluginHostResponse::DataMutationLookup {
|
||||
outcome: if matches!(self.fault, Fault::LookupExpired) {
|
||||
PluginDataMutationLookup::Expired
|
||||
} else {
|
||||
s.receipts
|
||||
.get(&operation_key)
|
||||
.cloned()
|
||||
.map(PluginDataMutationLookup::Committed)
|
||||
.unwrap_or(PluginDataMutationLookup::NotRecorded)
|
||||
},
|
||||
}
|
||||
}
|
||||
PluginHostRequest::DataGet { .. } => PluginHostResponse::DataCell {
|
||||
cell: PluginDataCell::NeverWritten,
|
||||
},
|
||||
PluginHostRequest::DataMutate { operation_key, .. } => {
|
||||
if matches!(self.fault, Fault::DataUnknown) {
|
||||
return Err(rejected(
|
||||
PluginHostErrorCode::Indeterminate,
|
||||
PluginEffectObservation::Indeterminate,
|
||||
));
|
||||
}
|
||||
s.writes += 1;
|
||||
let receipt = PluginDataReceipt {
|
||||
mutation_id: "write-1".into(),
|
||||
result_digest: "result".into(),
|
||||
};
|
||||
assert!(s.receipts.insert(operation_key, receipt.clone()).is_none());
|
||||
PluginHostResponse::DataMutated { receipt }
|
||||
}
|
||||
PluginHostRequest::Open { .. } => PluginHostResponse::Opened,
|
||||
PluginHostRequest::Send { request, .. } => {
|
||||
if matches!(self.fault, Fault::HttpUnknown) {
|
||||
return Err(rejected(
|
||||
PluginHostErrorCode::Indeterminate,
|
||||
PluginEffectObservation::Indeterminate,
|
||||
));
|
||||
}
|
||||
let response = if let Some(response) = s.http.get(&request.effect_key) {
|
||||
response.clone()
|
||||
} else {
|
||||
s.sends += 1;
|
||||
let response = PluginHttpResponse {
|
||||
status: 200,
|
||||
headers: vec![],
|
||||
body: b"ok".to_vec(),
|
||||
};
|
||||
s.http.insert(request.effect_key, response.clone());
|
||||
response
|
||||
};
|
||||
PluginHostResponse::Response { response }
|
||||
}
|
||||
_ => {
|
||||
return Err(rejected(
|
||||
PluginHostErrorCode::Unsupported,
|
||||
PluginEffectObservation::NotDispatched,
|
||||
));
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
fn context(c: &CompiledService) -> InvocationContext {
|
||||
let namespace = ("notes".into(), "note".into(), "1".into());
|
||||
InvocationContext {
|
||||
workspace: "workspace".into(),
|
||||
installation: "installation".into(),
|
||||
plan: "plan".into(),
|
||||
subject: "worker".into(),
|
||||
invocation_id: "ignored-before-begin".into(),
|
||||
artifact: c.identity().clone(),
|
||||
tool_name: "ticket_events".into(),
|
||||
grants: Grants {
|
||||
logging: true,
|
||||
connection_slots: ["api".into()].into(),
|
||||
ticket_events: true,
|
||||
ticket_read: true,
|
||||
ticket_data_read: [namespace.clone()].into(),
|
||||
ticket_data_write: [namespace].into(),
|
||||
},
|
||||
deadline: Instant::now() + Duration::from_secs(10),
|
||||
cancellation: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
async fn host(c: &CompiledService, owner: Arc<Owner>) -> Arc<OwnerHost> {
|
||||
Arc::new(
|
||||
OwnerHost::begin(
|
||||
owner,
|
||||
context(c),
|
||||
PluginConsumer::Consumer {
|
||||
consumer_id: "consumer".into(),
|
||||
event_id: "event".into(),
|
||||
export_name: "ticket_events".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
fn envelope() -> PluginTicketEventEnvelope {
|
||||
PluginTicketEventEnvelope {
|
||||
event_id: "event".into(),
|
||||
event_type: PluginTicketEventType::Changed,
|
||||
version: 1,
|
||||
occurred_at: "2026-10-30T00:00:00Z".into(),
|
||||
ticket_id: "T-796".into(),
|
||||
domain_event_ref: "domain-event".into(),
|
||||
content_digest: "digest".into(),
|
||||
correlation_id: "correlation".into(),
|
||||
causation_id: None,
|
||||
attempt: 1,
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn authored_service_recovers_with_receipt_lookup_before_rebuilding_a_mutation() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let c = compile(&ex);
|
||||
let owner = Owner::new(Fault::None);
|
||||
let host = host(&c, owner.clone()).await;
|
||||
assert!(matches!(
|
||||
ex.service_start(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
r#"{"mode":"normal"}"#
|
||||
)
|
||||
.await
|
||||
.outcome,
|
||||
Ok(ServiceOutput::Started)
|
||||
));
|
||||
for attempt in 1..=2 {
|
||||
let mut e = envelope();
|
||||
e.attempt = attempt;
|
||||
let result = ex
|
||||
.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&e,
|
||||
r#"{"mode":"normal"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result.outcome,
|
||||
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
|
||||
),
|
||||
"{result:?}"
|
||||
);
|
||||
assert!(matches!(
|
||||
result.effect,
|
||||
host::errors::EffectObservation::ResponseObserved
|
||||
));
|
||||
}
|
||||
assert!(matches!(
|
||||
ex.service_stop(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
"normal"
|
||||
)
|
||||
.await
|
||||
.outcome,
|
||||
Ok(ServiceOutput::Stopped)
|
||||
));
|
||||
let state = owner.state.lock().unwrap();
|
||||
assert_eq!(state.writes, 1);
|
||||
assert_eq!(state.sends, 1);
|
||||
assert_eq!(
|
||||
state
|
||||
.calls
|
||||
.iter()
|
||||
.filter(|r| matches!(r, PluginHostRequest::DataGet { .. }))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
state
|
||||
.calls
|
||||
.iter()
|
||||
.filter(|r| matches!(r, PluginHostRequest::DataLookupMutation { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn lifecycle_hooks_allow_logging_but_never_open_resolve_read_data_or_http() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let c = compile(&ex);
|
||||
let owner = Owner::new(Fault::None);
|
||||
let host = host(&c, owner.clone()).await;
|
||||
for mode in ["open", "resolve", "read", "data", "http"] {
|
||||
let config = format!(r#"{{"mode":"{mode}"}}"#);
|
||||
let start = ex
|
||||
.service_start(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&config,
|
||||
)
|
||||
.await;
|
||||
let stop = ex
|
||||
.service_stop(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
mode,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(start.outcome, Err(ExecutionError::Trap(_))),
|
||||
"{mode}: {start:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(stop.outcome, Err(ExecutionError::Trap(_))),
|
||||
"{mode}: {stop:?}"
|
||||
);
|
||||
}
|
||||
let s = owner.state.lock().unwrap();
|
||||
assert!(s.calls.iter().all(|r| matches!(
|
||||
r,
|
||||
PluginHostRequest::Begin { .. } | PluginHostRequest::Authorize { .. }
|
||||
)));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn guest_ack_cannot_hide_unknown_http_data_or_failed_receipt_lookup() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let c = compile(&ex);
|
||||
for fault in [
|
||||
Fault::HttpUnknown,
|
||||
Fault::DataUnknown,
|
||||
Fault::LookupUnavailable,
|
||||
Fault::LookupExpired,
|
||||
] {
|
||||
let host = host(&c, Owner::new(fault)).await;
|
||||
let result = ex
|
||||
.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&envelope(),
|
||||
r#"{"mode":"swallow-unknown"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
result.outcome,
|
||||
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
|
||||
),
|
||||
"{result:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
result.effect,
|
||||
host::errors::EffectObservation::Indeterminate
|
||||
),
|
||||
"{result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn cancellation_during_effect_wait_preserves_unknown_observation() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let c = compile(&ex);
|
||||
for fault in [Fault::HttpWait, Fault::DataWait] {
|
||||
let owner = Owner::new(fault);
|
||||
let host = host(&c, owner.clone()).await;
|
||||
let cancellation = host.context().cancellation.clone();
|
||||
let e = envelope();
|
||||
let (result, ()) = tokio::time::timeout(Duration::from_secs(5), async {
|
||||
tokio::join!(
|
||||
ex.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&e,
|
||||
r#"{"mode":"normal"}"#
|
||||
),
|
||||
async {
|
||||
owner.waiting.notified().await;
|
||||
cancellation.cancel();
|
||||
}
|
||||
)
|
||||
})
|
||||
.await
|
||||
.expect("effect was not dispatched");
|
||||
assert!(
|
||||
matches!(result.outcome, Err(ExecutionError::Cancelled)),
|
||||
"{result:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
result.effect,
|
||||
host::errors::EffectObservation::Indeterminate
|
||||
),
|
||||
"{result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn deleted_metadata_never_resolves_a_ticket_and_live_read_denial_remains_a_failure() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let c = compile(&ex);
|
||||
let owner = Owner::new(Fault::ReadDenied);
|
||||
let host = host(&c, owner.clone()).await;
|
||||
let result = ex
|
||||
.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&envelope(),
|
||||
r#"{"mode":"normal"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
result.outcome,
|
||||
Ok(ServiceOutput::Event(
|
||||
PluginServiceEventResult::RetryAfter { .. }
|
||||
))
|
||||
));
|
||||
let before = owner.state.lock().unwrap().calls.len();
|
||||
let mut e = envelope();
|
||||
e.event_type = PluginTicketEventType::Deleted;
|
||||
e.content_digest.clear();
|
||||
let result = ex
|
||||
.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host,
|
||||
"ticket_events",
|
||||
&e,
|
||||
r#"{"mode":"normal"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
result.outcome,
|
||||
Ok(ServiceOutput::Event(PluginServiceEventResult::Ack))
|
||||
));
|
||||
assert!(
|
||||
owner.state.lock().unwrap().calls[before..]
|
||||
.iter()
|
||||
.all(|r| matches!(r, PluginHostRequest::Authorize { .. }))
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn service_manifest_rejects_old_world_subscription_and_grant_mismatches() {
|
||||
package(MANIFEST).unwrap();
|
||||
for bad in [
|
||||
MANIFEST.replace("@2.2.0", "@2.1.0"),
|
||||
MANIFEST.replace("ticket_changed", "arbitrary_event"),
|
||||
MANIFEST.replace("kind = \"ticket_events\"", "kind = \"ticket_read\""),
|
||||
MANIFEST.replace(
|
||||
"ticket_changed\", \"ticket_deleted",
|
||||
"ticket_changed\", \"ticket_changed",
|
||||
),
|
||||
MANIFEST.replace("surfaces = [\"service\"]", "surfaces = [\"tool\"]"),
|
||||
] {
|
||||
assert!(package(&bad).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn service_host_and_guest_limits_fail_before_publication() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let c = compile(&ex);
|
||||
let host = host(&c, Owner::new(Fault::None)).await;
|
||||
let invalid = ex
|
||||
.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&envelope(),
|
||||
r#"{"mode":"oversize-retry"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
invalid.outcome,
|
||||
Err(ExecutionError::InvalidOutput(_))
|
||||
));
|
||||
let mut ctx = host.context().clone();
|
||||
ctx.grants.ticket_events = false;
|
||||
let denied = ex
|
||||
.service_event(
|
||||
&c,
|
||||
ctx,
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&envelope(),
|
||||
r#"{"mode":"normal"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(
|
||||
denied.outcome,
|
||||
Err(ExecutionError::InvalidInput(_))
|
||||
));
|
||||
let mut e = envelope();
|
||||
e.version = 2;
|
||||
assert!(matches!(
|
||||
ex.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&e,
|
||||
"{}"
|
||||
)
|
||||
.await
|
||||
.outcome,
|
||||
Err(ExecutionError::InvalidInput(_))
|
||||
));
|
||||
e = envelope();
|
||||
e.correlation_id = "x".repeat(65536);
|
||||
assert!(matches!(
|
||||
ex.service_event(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
&e,
|
||||
"{}"
|
||||
)
|
||||
.await
|
||||
.outcome,
|
||||
Err(ExecutionError::InvalidInput(_))
|
||||
));
|
||||
let ctx = host.context().clone();
|
||||
ctx.cancellation.cancel();
|
||||
assert!(matches!(
|
||||
ex.service_start(&c, ctx, host.clone(), "ticket_events", "{}")
|
||||
.await
|
||||
.outcome,
|
||||
Err(ExecutionError::Cancelled)
|
||||
));
|
||||
}
|
||||
|
||||
fn startup_component(logging: bool) -> Vec<u8> {
|
||||
let mut resolve = wit_parser::Resolve::default();
|
||||
let (package, _) = resolve
|
||||
.push_dir(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../resources/plugin/wit-service"))
|
||||
.unwrap();
|
||||
let world = resolve
|
||||
.select_world(&[package], Some("ticket-service-plugin"))
|
||||
.unwrap();
|
||||
let core = wit_component::dummy_module(&resolve, world, wit_parser::ManglingAndAbi::Standard32);
|
||||
let wat = wasmprinter::print_bytes(core).unwrap();
|
||||
let init = wat
|
||||
.lines()
|
||||
.find(|l| l.contains("(export \"cm32p2_initialize\""))
|
||||
.unwrap();
|
||||
let index = init
|
||||
.split("(func ")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(')')
|
||||
.next()
|
||||
.unwrap();
|
||||
let needle = wat
|
||||
.lines()
|
||||
.find(|l| l.contains(&format!("(func (;{index};)")))
|
||||
.unwrap();
|
||||
assert!(needle.trim_end().ends_with("))"), "{needle}");
|
||||
let import = wat
|
||||
.lines()
|
||||
.find(|l| {
|
||||
l.contains(if logging {
|
||||
"yoi:host/logging@2"
|
||||
} else {
|
||||
"yoi:host/connections@2"
|
||||
}) && l.contains(if logging { "\"log\"" } else { "\"open\"" })
|
||||
})
|
||||
.unwrap();
|
||||
let imported_index = import
|
||||
.split("(func (;")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap();
|
||||
let body = if logging {
|
||||
"i32.const 0 ".repeat(7) + &format!("i32.const 128 call {imported_index}")
|
||||
} else {
|
||||
format!("i32.const 0 i32.const 0 i32.const 128 call {imported_index}")
|
||||
};
|
||||
let replacement = format!("{} {body})", needle.trim_end().strip_suffix(')').unwrap());
|
||||
let wat = wat
|
||||
.replace(needle, &replacement)
|
||||
.replace("(memory (;0;) 0)", "(memory (;0;) 1)");
|
||||
let mut core = wat::parse_str(wat).unwrap();
|
||||
wit_component::embed_component_metadata(
|
||||
&mut core,
|
||||
&resolve,
|
||||
world,
|
||||
wit_component::StringEncoding::UTF8,
|
||||
)
|
||||
.unwrap();
|
||||
wit_component::ComponentEncoder::default()
|
||||
.module(&core)
|
||||
.unwrap()
|
||||
.validate(true)
|
||||
.encode()
|
||||
.unwrap()
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn component_start_sections_never_receive_a_service_export_context() {
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
for logging in [false, true] {
|
||||
let p = package_bytes(MANIFEST, &startup_component(logging)).unwrap();
|
||||
let c = ex
|
||||
.compile_service(
|
||||
&p.bytes,
|
||||
&p.package_digest,
|
||||
&p.artifact_digest,
|
||||
p.artifact_size,
|
||||
)
|
||||
.unwrap();
|
||||
let owner = Owner::new(Fault::None);
|
||||
let host = host(&c, owner.clone()).await;
|
||||
let result = ex
|
||||
.service_start(
|
||||
&c,
|
||||
host.context().clone(),
|
||||
host.clone(),
|
||||
"ticket_events",
|
||||
r#"{"mode":"normal"}"#,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(result.outcome, Err(ExecutionError::Trap(_))),
|
||||
"{result:?}"
|
||||
);
|
||||
assert!(owner.state.lock().unwrap().calls.iter().all(|r| matches!(
|
||||
r,
|
||||
PluginHostRequest::Begin { .. } | PluginHostRequest::Authorize { .. }
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn artifact_delivery_binds_service_names_and_subscriptions_to_component_package() {
|
||||
use delivery::{
|
||||
ArtifactCache, ArtifactExpectation, ArtifactSource, ArtifactStream, CacheLimits,
|
||||
DeliveryError,
|
||||
};
|
||||
|
||||
struct Source {
|
||||
bytes: Vec<u8>,
|
||||
metadata: PluginArtifactDeliveryMetadata,
|
||||
}
|
||||
#[async_trait]
|
||||
impl ArtifactSource for Source {
|
||||
fn server_authority_identity(&self) -> &str {
|
||||
"service-test-authority"
|
||||
}
|
||||
async fn authorize(
|
||||
&self,
|
||||
_: &PluginArtifactDeliveryRequest,
|
||||
) -> Result<PluginArtifactDeliveryMetadata, DeliveryError> {
|
||||
Ok(self.metadata.clone())
|
||||
}
|
||||
async fn fetch(
|
||||
&self,
|
||||
_: &PluginArtifactDeliveryRequest,
|
||||
) -> Result<ArtifactStream, DeliveryError> {
|
||||
Ok(Box::pin(std::io::Cursor::new(self.bytes.clone())))
|
||||
}
|
||||
}
|
||||
fn metadata<T: serde::Serialize, U: serde::de::DeserializeOwned>(value: &T) -> U {
|
||||
serde_json::from_value(serde_json::to_value(value).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
let artifact = package(MANIFEST).unwrap();
|
||||
let verified = plugin::verify_plugin_artifact(
|
||||
&artifact.bytes,
|
||||
&artifact.package_digest,
|
||||
&artifact.artifact_digest,
|
||||
artifact.artifact_size,
|
||||
&PluginPackageLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let m = &verified.package.manifest;
|
||||
let schema = plugin::plugin_config_schema_identity(m);
|
||||
let descriptor = PluginArtifact {
|
||||
package_id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
version: m.version.clone(),
|
||||
description: m.description.clone(),
|
||||
package_digest: artifact.package_digest.clone(),
|
||||
artifact_digest: artifact.artifact_digest.clone(),
|
||||
artifact_size: artifact.artifact_size.try_into().unwrap(),
|
||||
schema_version: m.schema_version,
|
||||
world: m.runtime.world.clone(),
|
||||
required_imports: m.runtime.required_imports.clone(),
|
||||
tools: metadata(&m.tools),
|
||||
services: metadata(&m.services),
|
||||
connections: metadata(&m.connections),
|
||||
requested_grants: metadata(&m.requested_grants),
|
||||
data_collections: metadata(&m.data_collections),
|
||||
config_schema: PluginSchemaContribution {
|
||||
identity: schema.identity,
|
||||
version: schema.version,
|
||||
digest: schema.digest,
|
||||
namespace: schema.namespace,
|
||||
dcdl: m.config_schema.clone(),
|
||||
},
|
||||
revoked: false,
|
||||
revocation_reason: None,
|
||||
};
|
||||
assert_eq!(
|
||||
descriptor.services[0].subscriptions,
|
||||
["ticket_changed", "ticket_deleted"]
|
||||
);
|
||||
let request = PluginArtifactDeliveryRequest {
|
||||
plan_id: "service-plan".into(),
|
||||
plan_fingerprint: "service-plan-fingerprint".into(),
|
||||
installation_id: "installation".into(),
|
||||
artifact_digest: artifact.artifact_digest.clone(),
|
||||
};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cache = ArtifactCache::new(directory.path().join("cache"), CacheLimits::default()).unwrap();
|
||||
let mut source = Source {
|
||||
bytes: artifact.bytes.clone(),
|
||||
metadata: PluginArtifactDeliveryMetadata {
|
||||
workspace_id: "workspace".into(),
|
||||
request: request.clone(),
|
||||
artifact: descriptor.clone(),
|
||||
},
|
||||
};
|
||||
let resolved = cache
|
||||
.resolve(
|
||||
&source,
|
||||
"workspace",
|
||||
&request,
|
||||
&ArtifactExpectation::new(descriptor.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resolved.bytes(), artifact.bytes);
|
||||
|
||||
let mut renamed = descriptor.clone();
|
||||
renamed.services[0].name = "different_service".into();
|
||||
let mut narrowed = descriptor.clone();
|
||||
narrowed.services[0].subscriptions = vec!["ticket_changed".into()];
|
||||
let mut unknown = descriptor.clone();
|
||||
unknown.services[0].subscriptions = vec!["arbitrary_event".into()];
|
||||
let mut missing = descriptor.clone();
|
||||
missing.services.clear();
|
||||
for tampered in [renamed, narrowed, unknown, missing] {
|
||||
// Source metadata and the trusted descriptor agree; only verification
|
||||
// against the actual package can detect this mismatch, including hits.
|
||||
source.metadata.artifact = tampered.clone();
|
||||
let expected = ArtifactExpectation::new(tampered);
|
||||
assert!(matches!(
|
||||
cache
|
||||
.resolve(&source, "workspace", &request, &expected)
|
||||
.await,
|
||||
Err(DeliveryError::MetadataMismatch)
|
||||
));
|
||||
}
|
||||
source.metadata.artifact = descriptor.clone();
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(
|
||||
&source,
|
||||
"workspace",
|
||||
&request,
|
||||
&ArtifactExpectation::new(descriptor)
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.bytes(),
|
||||
artifact.bytes
|
||||
);
|
||||
}
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "ticket-test-guest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"yoi-plugin-pdk",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown 0.17.1",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.2.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-xid"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn 2.0.119",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.51.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.244.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-xid",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoi-plugin-pdk"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "ticket-test-guest"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
yoi-plugin-pdk = { path = "../../../plugin-pdk" }
|
||||
@@ -0,0 +1,38 @@
|
||||
schema_version = 2
|
||||
id = "ticket-test"
|
||||
name = "Ticket test"
|
||||
version = "1.0.0"
|
||||
surfaces = ["tool"]
|
||||
config_schema = "{}"
|
||||
|
||||
[runtime]
|
||||
kind = "wasm-component"
|
||||
component = "plugin.component.wasm"
|
||||
world = "yoi:plugin/ticket-tool-plugin@2.1.0"
|
||||
required_imports = ["yoi:host/errors@2.0.0", "yoi:host/objects@2.1.0", "yoi:host/ticket-read@2.1.0", "yoi:host/extension-data@2.1.0"]
|
||||
|
||||
[[tools]]
|
||||
name = "ticket_test"
|
||||
description = "Exercise the typed Ticket extension boundary"
|
||||
input_schema = { type = "string" }
|
||||
output_schema = { type = "null" }
|
||||
error_codes = ["failed"]
|
||||
|
||||
[[data_collections]]
|
||||
collection = "notes"
|
||||
schema_id = "note"
|
||||
schema_version = "1"
|
||||
schema = { type = "string", maxLength = 64 }
|
||||
|
||||
[[requested_grants]]
|
||||
kind = "ticket_read"
|
||||
[[requested_grants]]
|
||||
kind = "ticket_data_read"
|
||||
collection = "notes"
|
||||
schema_id = "note"
|
||||
schema_version = "1"
|
||||
[[requested_grants]]
|
||||
kind = "ticket_data_write"
|
||||
collection = "notes"
|
||||
schema_id = "note"
|
||||
schema_version = "1"
|
||||
@@ -0,0 +1,95 @@
|
||||
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput, wit_bindgen};
|
||||
wit_bindgen::generate!({
|
||||
path: "../../../../resources/plugin/wit-ticket",
|
||||
world: "ticket-tool-plugin",
|
||||
generate_all,
|
||||
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
|
||||
});
|
||||
use yoi::host2_1_0::{extension_data as data, objects, ticket_read};
|
||||
|
||||
fn namespace() -> data::Collection {
|
||||
data::Collection {
|
||||
collection: "notes".into(),
|
||||
schema_id: "note".into(),
|
||||
schema_version: "1".into(),
|
||||
}
|
||||
}
|
||||
fn address() -> data::Address {
|
||||
data::Address {
|
||||
namespace: namespace(),
|
||||
key: "main".into(),
|
||||
}
|
||||
}
|
||||
fn failure(error: yoi::host2_0_0::errors::HostError) -> ToolError {
|
||||
ToolError::failed(format!("{:?}", error.code))
|
||||
}
|
||||
fn handle(_: ToolContext, mode: String) -> yoi_plugin_pdk::Result<ToolOutput> {
|
||||
let ticket = if mode == "foreign" {
|
||||
unsafe { objects::Ticket::from_handle(0) }
|
||||
} else {
|
||||
objects::resolve_ticket("T-795", objects::Access::ExtensionWrite).map_err(failure)?
|
||||
};
|
||||
let ticket = if mode == "stale" {
|
||||
let handle = ticket.handle();
|
||||
drop(ticket);
|
||||
unsafe { objects::Ticket::from_handle(handle) }
|
||||
} else {
|
||||
ticket
|
||||
};
|
||||
let snapshot = ticket_read::read(&ticket).map_err(failure)?;
|
||||
if mode == "other-schema" {
|
||||
let mut a = address();
|
||||
a.namespace.schema_version = "2".into();
|
||||
data::get(&ticket, &a).map_err(failure)?;
|
||||
}
|
||||
if mode == "read" {
|
||||
return ToolOutput::json(snapshot.title, ());
|
||||
}
|
||||
let json = if mode == "bad-schema" {
|
||||
"{\"undeclared\":true}".into()
|
||||
} else {
|
||||
yoi_plugin_pdk::extension_data_json(&"note")?
|
||||
};
|
||||
let written = data::put(
|
||||
&ticket,
|
||||
&address(),
|
||||
&data::Expectation::NeverWritten,
|
||||
"put",
|
||||
&json,
|
||||
)
|
||||
.map_err(failure)?;
|
||||
assert!(matches!(
|
||||
data::get(&ticket, &address()).map_err(failure)?,
|
||||
data::Cell::Present(_)
|
||||
));
|
||||
let page = data::list(&ticket, &namespace(), None, 10).map_err(failure)?;
|
||||
assert_eq!(page.entries.len(), 1);
|
||||
let lookup = data::lookup_mutation(&ticket, &address(), "put").map_err(failure)?;
|
||||
assert!(matches!(lookup, data::MutationLookup::Committed(_)));
|
||||
let deleted =
|
||||
data::delete(&ticket, &address(), &written.mutation_id, "delete").map_err(failure)?;
|
||||
assert!(matches!(
|
||||
data::get(&ticket, &address()).map_err(failure)?,
|
||||
data::Cell::Deleted(_)
|
||||
));
|
||||
data::put(
|
||||
&ticket,
|
||||
&address(),
|
||||
&data::Expectation::LastWrite(deleted.mutation_id),
|
||||
"recreate",
|
||||
&json,
|
||||
)
|
||||
.map_err(failure)?;
|
||||
// Replay the original exact request even though the cell has since changed.
|
||||
let replay = data::put(
|
||||
&ticket,
|
||||
&address(),
|
||||
&data::Expectation::NeverWritten,
|
||||
"put",
|
||||
&json,
|
||||
)
|
||||
.map_err(failure)?;
|
||||
assert_eq!(replay.mutation_id, written.mutation_id);
|
||||
ToolOutput::json(snapshot.title, ())
|
||||
}
|
||||
yoi_plugin_pdk::export_ticket_component_tool!(TicketPlugin, handle);
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Real Rust PDK -> Component -> Wasmtime -> OwnerHost adapter regression.
|
||||
//! The deterministic owner here tests the adapter/ABI, not DB durability or
|
||||
//! production authorization; real owner/storage races belong to workspace-server.
|
||||
use async_trait::async_trait;
|
||||
use manifest::plugin::{self, PluginPackageLimits, PluginSourceKind};
|
||||
use server_api::*;
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
path::Path,
|
||||
process::Command,
|
||||
sync::{Arc, Mutex, OnceLock},
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
use tokio::time::Instant;
|
||||
use yoi_plugin_runtime::{
|
||||
host::{CancellationToken, Grants, InvocationContext},
|
||||
owner::{OwnerHost, PluginHostOwner},
|
||||
*,
|
||||
};
|
||||
|
||||
fn component() -> &'static [u8] {
|
||||
static COMPONENT: OnceLock<Vec<u8>> = OnceLock::new();
|
||||
COMPONENT.get_or_init(|| {
|
||||
// Fixture builds write only this explicitly isolated output directory.
|
||||
let target =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/t795-contracts/guest");
|
||||
let output = Command::new(env!("CARGO"))
|
||||
.args([
|
||||
"build",
|
||||
"--locked",
|
||||
"--offline",
|
||||
"--release",
|
||||
"--target",
|
||||
"wasm32-unknown-unknown",
|
||||
"--manifest-path",
|
||||
])
|
||||
.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/ticket-guest/Cargo.toml"))
|
||||
.arg("--target-dir")
|
||||
.arg(&target)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let core =
|
||||
fs::read(target.join("wasm32-unknown-unknown/release/ticket_test_guest.wasm")).unwrap();
|
||||
let bytes = wit_component::ComponentEncoder::default()
|
||||
.module(&core)
|
||||
.unwrap()
|
||||
.validate(true)
|
||||
.encode()
|
||||
.unwrap();
|
||||
fs::write(
|
||||
target.parent().unwrap().join("ticket-tool.component.wasm"),
|
||||
&bytes,
|
||||
)
|
||||
.unwrap();
|
||||
bytes
|
||||
})
|
||||
}
|
||||
fn artifact(manifest: &str) -> plugin::CanonicalPluginArtifact {
|
||||
let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/t795-contracts");
|
||||
fs::create_dir_all(&target).unwrap();
|
||||
let dir = tempfile::tempdir_in(target).unwrap();
|
||||
fs::write(dir.path().join("plugin.toml"), manifest).unwrap();
|
||||
fs::write(dir.path().join("plugin.component.wasm"), component()).unwrap();
|
||||
let package = plugin::read_plugin_directory(
|
||||
dir.path(),
|
||||
PluginSourceKind::Explicit,
|
||||
&PluginPackageLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
plugin::pack_plugin_package(&package, &PluginPackageLimits::default()).unwrap()
|
||||
}
|
||||
struct State {
|
||||
cell: PluginDataCell,
|
||||
receipts: BTreeMap<String, (PluginHostRequest, PluginDataReceipt)>,
|
||||
calls: Vec<PluginHostRequest>,
|
||||
}
|
||||
struct Owner {
|
||||
state: Mutex<State>,
|
||||
revoke_after_resolve: bool,
|
||||
}
|
||||
impl Owner {
|
||||
fn new(revoke_after_resolve: bool) -> Self {
|
||||
Self {
|
||||
state: Mutex::new(State {
|
||||
cell: PluginDataCell::NeverWritten,
|
||||
receipts: BTreeMap::new(),
|
||||
calls: vec![],
|
||||
}),
|
||||
revoke_after_resolve,
|
||||
}
|
||||
}
|
||||
}
|
||||
fn rejected(code: PluginHostErrorCode) -> PluginHostError {
|
||||
PluginHostError {
|
||||
code,
|
||||
effect: PluginEffectObservation::NotDispatched,
|
||||
diagnostic_id: "isolated-test".into(),
|
||||
}
|
||||
}
|
||||
#[async_trait]
|
||||
impl PluginHostOwner for Owner {
|
||||
async fn call(
|
||||
&self,
|
||||
request: PluginHostRequest,
|
||||
) -> Result<PluginHostResponse, PluginHostError> {
|
||||
let mut s = self.state.lock().unwrap();
|
||||
s.calls.push(request.clone());
|
||||
Ok(match request.clone() {
|
||||
PluginHostRequest::Begin { .. } => PluginHostResponse::Invocation {
|
||||
invocation_id: "logical-invocation".into(),
|
||||
expires_at_ms: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as i64
|
||||
+ 60_000,
|
||||
},
|
||||
PluginHostRequest::Authorize { .. } => PluginHostResponse::Authorized,
|
||||
PluginHostRequest::ResolveTicket { ticket_ref, .. } => {
|
||||
assert_eq!(ticket_ref, "T-795");
|
||||
PluginHostResponse::TicketResolved {
|
||||
ticket_id: "canonical-ticket".into(),
|
||||
}
|
||||
}
|
||||
PluginHostRequest::ReadTicket { ticket_id, .. } => {
|
||||
assert_eq!(ticket_id, "canonical-ticket");
|
||||
if self.revoke_after_resolve {
|
||||
return Err(rejected(PluginHostErrorCode::Denied));
|
||||
}
|
||||
PluginHostResponse::TicketRead {
|
||||
snapshot: PluginTicketSnapshot {
|
||||
title: "unchanged title".into(),
|
||||
case_state: PluginTicketCaseState::Open,
|
||||
content_digest: "unchanged digest".into(),
|
||||
body: "bounded body".into(),
|
||||
truncated: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
PluginHostRequest::DataGet { .. } => PluginHostResponse::DataCell {
|
||||
cell: s.cell.clone(),
|
||||
},
|
||||
PluginHostRequest::DataList { .. } => PluginHostResponse::DataListed {
|
||||
page: PluginDataPage {
|
||||
entries: vec![PluginDataEntry {
|
||||
key: "main".into(),
|
||||
cell: s.cell.clone(),
|
||||
}],
|
||||
next_cursor: None,
|
||||
},
|
||||
},
|
||||
PluginHostRequest::DataLookupMutation { operation_key, .. } => {
|
||||
PluginHostResponse::DataMutationLookup {
|
||||
outcome: s
|
||||
.receipts
|
||||
.get(&operation_key)
|
||||
.map(|(_, r)| PluginDataMutationLookup::Committed(r.clone()))
|
||||
.unwrap_or(PluginDataMutationLookup::NotRecorded),
|
||||
}
|
||||
}
|
||||
PluginHostRequest::DataMutate {
|
||||
operation_key,
|
||||
mutation,
|
||||
..
|
||||
} => {
|
||||
if let Some((original, receipt)) = s.receipts.get(&operation_key) {
|
||||
if *original != request {
|
||||
return Err(rejected(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
return Ok(PluginHostResponse::DataMutated {
|
||||
receipt: receipt.clone(),
|
||||
});
|
||||
}
|
||||
let observed = match &s.cell {
|
||||
PluginDataCell::NeverWritten => PluginDataExpectation::NeverWritten,
|
||||
PluginDataCell::Present { last_write, .. } => {
|
||||
PluginDataExpectation::LastWrite(last_write.clone())
|
||||
}
|
||||
PluginDataCell::Deleted { last_delete } => {
|
||||
PluginDataExpectation::LastWrite(last_delete.clone())
|
||||
}
|
||||
};
|
||||
let id = format!("mutation-{operation_key}");
|
||||
s.cell = match mutation {
|
||||
PluginDataMutation::Put {
|
||||
expected,
|
||||
value_json,
|
||||
} => {
|
||||
if expected != observed {
|
||||
return Err(rejected(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
PluginDataCell::Present {
|
||||
value_json,
|
||||
last_write: id.clone(),
|
||||
}
|
||||
}
|
||||
PluginDataMutation::Delete {
|
||||
expected_last_write,
|
||||
} => {
|
||||
if PluginDataExpectation::LastWrite(expected_last_write) != observed {
|
||||
return Err(rejected(PluginHostErrorCode::Conflict));
|
||||
}
|
||||
PluginDataCell::Deleted {
|
||||
last_delete: id.clone(),
|
||||
}
|
||||
}
|
||||
};
|
||||
let receipt = PluginDataReceipt {
|
||||
mutation_id: id,
|
||||
result_digest: "result-digest".into(),
|
||||
};
|
||||
s.receipts.insert(operation_key, (request, receipt.clone()));
|
||||
PluginHostResponse::DataMutated { receipt }
|
||||
}
|
||||
_ => return Err(rejected(PluginHostErrorCode::Unsupported)),
|
||||
})
|
||||
}
|
||||
}
|
||||
fn context(compiled: &CompiledTool) -> InvocationContext {
|
||||
let namespace: (String, String, String) = ("notes".into(), "note".into(), "1".into());
|
||||
InvocationContext {
|
||||
workspace: "ws".into(),
|
||||
installation: "installation".into(),
|
||||
plan: "plan".into(),
|
||||
subject: "subject".into(),
|
||||
invocation_id: "not-authority".into(),
|
||||
artifact: compiled.identity().clone(),
|
||||
tool_name: "ticket_test".into(),
|
||||
grants: Grants {
|
||||
ticket_read: true,
|
||||
ticket_data_read: [namespace.clone()].into(),
|
||||
ticket_data_write: [namespace].into(),
|
||||
..Default::default()
|
||||
},
|
||||
deadline: Instant::now() + Duration::from_secs(60),
|
||||
cancellation: CancellationToken::new(),
|
||||
}
|
||||
}
|
||||
async fn run(
|
||||
ex: &Executor,
|
||||
compiled: &CompiledTool,
|
||||
owner: Arc<Owner>,
|
||||
mode: &str,
|
||||
granted: bool,
|
||||
) -> Result<InvocationResult, ExecutionError> {
|
||||
let mut ctx = context(compiled);
|
||||
if !granted {
|
||||
ctx.grants.ticket_data_write.clear();
|
||||
}
|
||||
let host = Arc::new(
|
||||
OwnerHost::begin(
|
||||
owner,
|
||||
ctx,
|
||||
PluginConsumer::ToolCall {
|
||||
tool_call_id: "call".into(),
|
||||
tool_name: "ticket_test".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
ex.invoke(
|
||||
compiled,
|
||||
host.context().clone(),
|
||||
host,
|
||||
"ticket_test",
|
||||
&format!("\"{mode}\""),
|
||||
"{}",
|
||||
)
|
||||
.await
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn actual_component_resolves_reads_mutates_lists_and_reconciles_receipts() {
|
||||
let artifact = artifact(include_str!("ticket-guest/plugin.toml"));
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let compiled = ex
|
||||
.compile(
|
||||
&artifact.bytes,
|
||||
&artifact.package_digest,
|
||||
&artifact.artifact_digest,
|
||||
artifact.artifact_size,
|
||||
)
|
||||
.unwrap();
|
||||
let owner = Arc::new(Owner::new(false));
|
||||
let result = run(&ex, &compiled, owner.clone(), "all", true)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.output.summary, "unchanged title");
|
||||
let s = owner.state.lock().unwrap();
|
||||
assert_eq!(s.receipts.len(), 3); // put/delete/recreate, not a fourth replay
|
||||
assert!(
|
||||
matches!(&s.cell, PluginDataCell::Present { last_write, .. } if last_write == "mutation-recreate")
|
||||
);
|
||||
assert_eq!(
|
||||
s.calls
|
||||
.iter()
|
||||
.filter(|r| matches!(r, PluginHostRequest::DataMutate { .. }))
|
||||
.count(),
|
||||
4
|
||||
);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn actual_component_denies_old_grants_foreign_resources_revocation_and_bad_schema() {
|
||||
let artifact = artifact(include_str!("ticket-guest/plugin.toml"));
|
||||
let ex = Executor::new(Limits::default()).unwrap();
|
||||
let compiled = ex
|
||||
.compile(
|
||||
&artifact.bytes,
|
||||
&artifact.package_digest,
|
||||
&artifact.artifact_digest,
|
||||
artifact.artifact_size,
|
||||
)
|
||||
.unwrap();
|
||||
for (mode, revoked, granted) in [
|
||||
("read", true, true),
|
||||
("foreign", false, true),
|
||||
("stale", false, true),
|
||||
("other-schema", false, true),
|
||||
("read", false, false),
|
||||
("bad-schema", false, true),
|
||||
] {
|
||||
let owner = Arc::new(Owner::new(revoked));
|
||||
assert!(
|
||||
run(&ex, &compiled, owner.clone(), mode, granted)
|
||||
.await
|
||||
.is_err(),
|
||||
"{mode}"
|
||||
);
|
||||
assert!(
|
||||
!owner
|
||||
.state
|
||||
.lock()
|
||||
.unwrap()
|
||||
.calls
|
||||
.iter()
|
||||
.any(|r| matches!(r, PluginHostRequest::DataMutate { .. })),
|
||||
"rejection precedes write"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,11 @@ license.workspace = true
|
||||
[features]
|
||||
default = ["stream"]
|
||||
stream = ["dep:tokio"]
|
||||
typescript = ["dep:ts-rs", "json-schema"]
|
||||
json-schema = ["dep:schemars"]
|
||||
typescript = ["dep:ts-rs", "json-schema", "error-contract/typescript"]
|
||||
json-schema = ["dep:schemars", "error-contract/json-schema"]
|
||||
|
||||
[dependencies]
|
||||
error-contract.workspace = true
|
||||
schemars = { workspace = true, optional = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -3,7 +3,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let check = std::env::args()
|
||||
.skip(1)
|
||||
.any(|argument| argument == "--check");
|
||||
let outputs = [
|
||||
let mut outputs = vec![
|
||||
(
|
||||
protocol::typescript::generated_typescript_path(),
|
||||
protocol::typescript::generated_protocol_types(),
|
||||
@@ -13,6 +13,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
protocol::typescript::generated_protocol_validator(),
|
||||
),
|
||||
];
|
||||
outputs.extend(protocol::typescript::generated_failure_contract_artifacts());
|
||||
|
||||
for (path, expected) in outputs {
|
||||
if check {
|
||||
|
||||
+133
-26
@@ -52,7 +52,7 @@ pub enum AuthenticatedInputSource {
|
||||
/// Assigned whenever a serialized tracked method crosses an untrusted
|
||||
/// protocol boundary. Receivers must handle it exactly like public input.
|
||||
UntrustedWire,
|
||||
/// Stamped only by the operator-owned standalone in-process transport.
|
||||
/// Stamped only by the operator-owned Standalone Host after local admission.
|
||||
/// This is not a Backend Account or authority accepted from wire fields.
|
||||
LocalOperator,
|
||||
Account {
|
||||
@@ -103,6 +103,33 @@ impl AuthenticatedInputSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Server-owned receipt partition, independent of client-visible ACK identity.
|
||||
/// Console Notify keeps its original bounded ID without sharing HTTP/chat receipts.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum NotificationReceiptScope {
|
||||
#[default]
|
||||
Source,
|
||||
BrowserConsole,
|
||||
}
|
||||
|
||||
impl NotificationReceiptScope {
|
||||
pub fn namespace(self, source_namespace: String) -> String {
|
||||
match self {
|
||||
Self::Source => source_namespace,
|
||||
Self::BrowserConsole => format!("browser-console:{source_namespace}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Like provenance, a receipt partition is rebound by the authenticated transport.
|
||||
fn ignore_wire_notification_scope<'de, D: serde::Deserializer<'de>>(
|
||||
deserializer: D,
|
||||
) -> Result<NotificationReceiptScope, D::Error> {
|
||||
let _ = serde::de::IgnoredAny::deserialize(deserializer)?;
|
||||
Ok(NotificationReceiptScope::Source)
|
||||
}
|
||||
|
||||
/// Caller-owned identity for one state-changing Worker command.
|
||||
///
|
||||
/// Commands operate on the controller state at admission time, not a caller's
|
||||
@@ -311,6 +338,8 @@ pub enum Method {
|
||||
NotifyTracked {
|
||||
notification_request_id: String,
|
||||
message: String,
|
||||
#[serde(default, deserialize_with = "ignore_wire_notification_scope")]
|
||||
receipt_scope: NotificationReceiptScope,
|
||||
#[serde(default, deserialize_with = "ignore_wire_input_source")]
|
||||
source: AuthenticatedInputSource,
|
||||
},
|
||||
@@ -399,16 +428,13 @@ pub enum Method {
|
||||
|
||||
/// Typed lifecycle events sent from a child Worker to its parent.
|
||||
///
|
||||
/// Delivered as `Method::WorkerEvent` over the parent's Unix socket. The
|
||||
/// parent Controller always applies variant-specific side effects
|
||||
/// (registry / worker-allocation updates). Agent-visible variants are also
|
||||
/// queued into the notification buffer; control-plane-only variants are
|
||||
/// not injected into the parent's LLM context.
|
||||
/// Delivered in-process to the parent Controller's protocol intake. Agent-visible variants
|
||||
/// are queued into the notification buffer. Reports are advisory: receivers must check the
|
||||
/// actual child session before treating its work as complete.
|
||||
///
|
||||
/// Transport is fire-and-forget; receivers must tolerate out-of-order
|
||||
/// delivery (e.g. `TurnEnded` arriving after `ShutDown` for the same
|
||||
/// child Worker).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// Retained legacy control-only variants can be decoded from saved history, but do not
|
||||
/// register live children, delegate scope, or establish a socket callback.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
@@ -429,16 +455,8 @@ pub enum WorkerEvent {
|
||||
/// Child has stopped (controller loop is exiting).
|
||||
ShutDown { worker_name: String },
|
||||
|
||||
/// Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.
|
||||
///
|
||||
/// Control-plane only: receivers apply registry side effects and
|
||||
/// propagate upward, but do not expose this as an agent notification.
|
||||
///
|
||||
/// The parent uses this to add the grandchild to its own
|
||||
/// `spawned_workers.json` so it can manage the grandchild directly
|
||||
/// even if the intermediate child dies. The parent then re-fires
|
||||
/// this event upward (if it has a parent of its own) to maintain
|
||||
/// the chain to root.
|
||||
/// Legacy process-child scope report retained for saved Session decoding only.
|
||||
/// Live Controllers ignore it; only the Host-owned Internal registry grants child scope.
|
||||
ScopeSubDelegated {
|
||||
/// Sub-delegating Worker (= the sender itself).
|
||||
parent_worker: String,
|
||||
@@ -982,6 +1000,17 @@ pub enum SessionEntryProvenance {
|
||||
LegacyUnknown,
|
||||
}
|
||||
|
||||
/// Public use decisions contain no Feature identity or private payload. Absence
|
||||
/// denotes the older projection contract, not permission to expose System input.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SessionEntryUsePolicy {
|
||||
pub observation_readable: bool,
|
||||
pub memory_evidence: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
@@ -994,6 +1023,8 @@ pub struct SessionSnapshotEntry {
|
||||
pub provenance: SessionEntryProvenance,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub derived_from: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub use_policy: Option<SessionEntryUsePolicy>,
|
||||
#[serde(flatten)]
|
||||
pub data: SessionSnapshotEntryData,
|
||||
}
|
||||
@@ -1180,12 +1211,10 @@ pub enum Event {
|
||||
},
|
||||
/// One agent-injected system item committed to history.
|
||||
///
|
||||
/// Carries the JSON form of `session_store::SystemItem`. Covers
|
||||
/// `Method::Notify` echoes, child-Worker lifecycle events from
|
||||
/// `Method::WorkerEvent`, `@<path>` resolution payloads, and any future
|
||||
/// Clients dispatch on the `kind` tag for typed rendering instead
|
||||
/// of parsing free-text prefixes like `[Notification] …` or
|
||||
/// `[File: …]`.
|
||||
/// Carries the allowlisted JSON form of `session_store::PublicSystemItem`,
|
||||
/// not the storage record. Its descriptive `kind` is independent from the
|
||||
/// typed `presentation` used for notification, reminder, or context rendering.
|
||||
/// Private Feature payloads and prompt provenance are never event data.
|
||||
///
|
||||
/// One event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and
|
||||
/// wire-side are 1:1.
|
||||
@@ -1497,6 +1526,11 @@ pub enum Event {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct Alert {
|
||||
/// Stable only within the emitting live Worker; not durable alert history.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub position: Option<AlertPosition>,
|
||||
pub level: AlertLevel,
|
||||
pub source: AlertSource,
|
||||
pub message: String,
|
||||
@@ -1504,6 +1538,18 @@ pub struct Alert {
|
||||
pub timestamp_ms: i64,
|
||||
}
|
||||
|
||||
/// Display anchor captured at emission, resolved when pending text is committed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum AlertPosition {
|
||||
Start,
|
||||
AfterEntry { entry_id: String },
|
||||
AfterToolCall { call_id: String },
|
||||
AfterInFlight { block_id: u64 },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
@@ -1704,6 +1750,9 @@ pub struct InFlightCompaction {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
|
||||
pub struct InFlightSnapshot {
|
||||
/// Live-Worker identities parallel to the blocks actually included below.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub block_ids: Vec<u64>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub blocks: Vec<InFlightBlock>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -2222,6 +2271,29 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notification_receipt_scope_is_not_selected_by_wire_input() {
|
||||
for scope in [
|
||||
serde_json::json!("browser_console"),
|
||||
serde_json::json!({"forged":true}),
|
||||
serde_json::Value::Null,
|
||||
] {
|
||||
let method: Method = serde_json::from_value(serde_json::json!({
|
||||
"method":"notify_tracked", "params":{
|
||||
"notification_request_id":"original", "message":"advisory",
|
||||
"receipt_scope":scope,
|
||||
"source":{"kind":"account","account_id":"forged"}
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(matches!(method, Method::NotifyTracked {
|
||||
receipt_scope: NotificationReceiptScope::Source,
|
||||
source: AuthenticatedInputSource::UntrustedWire,
|
||||
notification_request_id, ..
|
||||
} if notification_request_id == "original"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notify_rejects_typed_intent_instead_of_silently_discarding_it() {
|
||||
for method in ["notify", "notify_tracked"] {
|
||||
@@ -2601,6 +2673,7 @@ mod tests {
|
||||
session: SessionSnapshot {
|
||||
pending_submissions: PendingSubmissionsSnapshot::default(),
|
||||
entries: vec![SessionSnapshotEntry {
|
||||
use_policy: None,
|
||||
entry_id: "entry-1".into(),
|
||||
timestamp: 1,
|
||||
provenance: SessionEntryProvenance::HumanInput,
|
||||
@@ -2721,6 +2794,7 @@ mod tests {
|
||||
},
|
||||
state: WorkerStatus::Running.into(),
|
||||
in_flight: InFlightSnapshot {
|
||||
block_ids: Vec::new(),
|
||||
blocks: vec![
|
||||
InFlightBlock::Text {
|
||||
text: "hel".into(),
|
||||
@@ -3048,9 +3122,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alert_ordering_metadata_is_optional_and_uses_tagged_wire_anchors() {
|
||||
let legacy = serde_json::json!({ "level": "warn", "source": "worker", "message": "old", "timestamp_ms": 42 });
|
||||
let alert: Alert = serde_json::from_value(legacy.clone()).unwrap();
|
||||
assert!(alert.id.is_none());
|
||||
assert!(alert.position.is_none());
|
||||
assert_eq!(serde_json::to_value(alert).unwrap(), legacy);
|
||||
for position in [
|
||||
serde_json::json!({ "kind": "start" }),
|
||||
serde_json::json!({ "kind": "after_entry", "entry_id": "entry-1" }),
|
||||
serde_json::json!({ "kind": "after_tool_call", "call_id": "call-1" }),
|
||||
serde_json::json!({ "kind": "after_in_flight", "block_id": 7 }),
|
||||
] {
|
||||
let mut wire = legacy.clone();
|
||||
wire["id"] = serde_json::json!("alert-1");
|
||||
wire["position"] = position;
|
||||
let alert: Alert = serde_json::from_value(wire.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(alert).unwrap(), wire);
|
||||
}
|
||||
let old_snapshot: InFlightSnapshot = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||
assert!(old_snapshot.block_ids.is_empty());
|
||||
assert_eq!(
|
||||
serde_json::to_value(old_snapshot).unwrap(),
|
||||
serde_json::json!({})
|
||||
);
|
||||
let wire =
|
||||
serde_json::json!({"blocks": [{"kind": "text", "text": "pending"}], "block_ids": [7]});
|
||||
let snapshot: InFlightSnapshot = serde_json::from_value(wire.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(snapshot).unwrap(), wire);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_alert_format() {
|
||||
let event = Event::Alert(Alert {
|
||||
id: None,
|
||||
position: None,
|
||||
level: AlertLevel::Warn,
|
||||
source: AlertSource::Compactor,
|
||||
message: "compaction failed".into(),
|
||||
|
||||
@@ -4,25 +4,25 @@ use serde_json::{Map, Value};
|
||||
use ts_rs::{Config, TS};
|
||||
|
||||
use crate::{
|
||||
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
|
||||
CompactionTrigger, CompletionContext, CompletionEntry, CompletionKind, ContextTokenSource,
|
||||
ContextUsage, ErrorCode, Event, FeatureInvocation, FeatureInvocationDescriptor,
|
||||
FeatureInvocationIdentity, FeatureInvocationResult, FeatureInvocationStatus,
|
||||
FeatureInvocationSyntax, Greeting, InFlightBlock, InFlightCompaction, InFlightSnapshot,
|
||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
||||
InvocationArgumentDescriptor, InvocationArgumentType, InvocationArgumentValue,
|
||||
InvocationClientAdapter, InvocationCompletion, InvocationValue, InvokeKind, MemoryWorkerEvent,
|
||||
Method, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef,
|
||||
PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, ReasoningConfig,
|
||||
RewindSummary, RunFailureKind, RunResult, RunResumeSource, RunYieldReason, ScopeRule, Segment,
|
||||
SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
||||
SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot,
|
||||
SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition,
|
||||
SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef,
|
||||
WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
||||
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
|
||||
WorkerStateSnapshot, WorkerStatus,
|
||||
Alert, AlertLevel, AlertPosition, AlertSource, CommandEvent, CommandSnapshot, CommandStatus,
|
||||
CommandStream, CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState,
|
||||
CompactionPhase, CompactionTrigger, CompletionContext, CompletionEntry, CompletionKind,
|
||||
ContextTokenSource, ContextUsage, ErrorCode, Event, FeatureInvocation,
|
||||
FeatureInvocationDescriptor, FeatureInvocationIdentity, FeatureInvocationResult,
|
||||
FeatureInvocationStatus, FeatureInvocationSyntax, Greeting, InFlightBlock, InFlightCompaction,
|
||||
InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef,
|
||||
InternalWorkerSnapshot, InvocationArgumentDescriptor, InvocationArgumentType,
|
||||
InvocationArgumentValue, InvocationClientAdapter, InvocationCompletion, InvocationValue,
|
||||
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
|
||||
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
|
||||
ReasoningConfig, RewindSummary, RunFailureKind, RunResult, RunResumeSource, RunYieldReason,
|
||||
ScopeRule, Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance,
|
||||
SessionEntryUsePolicy, SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole,
|
||||
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
|
||||
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
|
||||
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
|
||||
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
|
||||
subscription::{
|
||||
EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES,
|
||||
MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS,
|
||||
@@ -65,6 +65,7 @@ pub fn generated_protocol_types() -> String {
|
||||
);
|
||||
|
||||
push_decl::<AlertLevel>(&cfg, &mut output);
|
||||
push_decl::<AlertPosition>(&cfg, &mut output);
|
||||
push_decl::<AlertSource>(&cfg, &mut output);
|
||||
push_decl::<CompletionKind>(&cfg, &mut output);
|
||||
push_decl::<CompletionContext>(&cfg, &mut output);
|
||||
@@ -116,6 +117,7 @@ pub fn generated_protocol_types() -> String {
|
||||
push_decl::<InFlightCompaction>(&cfg, &mut output);
|
||||
push_decl::<InFlightSnapshot>(&cfg, &mut output);
|
||||
push_decl::<SessionEntryProvenance>(&cfg, &mut output);
|
||||
push_decl::<SessionEntryUsePolicy>(&cfg, &mut output);
|
||||
push_decl::<SessionMessageRole>(&cfg, &mut output);
|
||||
push_decl::<SessionContentPart>(&cfg, &mut output);
|
||||
push_decl::<SessionToolAttachment>(&cfg, &mut output);
|
||||
@@ -173,6 +175,42 @@ pub fn generated_protocol_types() -> String {
|
||||
normalize_typescript(output)
|
||||
}
|
||||
|
||||
/// Shared failure artifacts use the existing protocol generator. They do not change any
|
||||
/// production Worker/HTTP payload; those boundaries opt in with domain-specific details.
|
||||
pub fn generated_failure_contract_artifacts() -> Vec<(PathBuf, String)> {
|
||||
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let types = format!(
|
||||
"// @generated by `cargo run -p protocol --example generate_typescript --features typescript`\n// Untrusted wire DTOs; validation and current-context reprojection are required.\n\nexport const FAILURE_LIMITS = {{ bytes: {}, depth: {}, contexts: {}, causes: {}, diagnostics: {}, explanationBytes: {}, identifierBytes: {} }} as const;\n\n{}",
|
||||
error_contract::MAX_FAILURE_BYTES,
|
||||
error_contract::MAX_JSON_DEPTH,
|
||||
error_contract::MAX_CONTEXTS,
|
||||
error_contract::MAX_CAUSES,
|
||||
error_contract::MAX_DIAGNOSTICS,
|
||||
error_contract::MAX_EXPLANATION_BYTES,
|
||||
error_contract::MAX_IDENTIFIER_BYTES,
|
||||
error_contract::schema::typescript_declarations(),
|
||||
);
|
||||
let render = |schema: schemars::Schema| {
|
||||
let mut text = serde_json::to_string_pretty(&schema).expect("failure schema serializes");
|
||||
text.push('\n');
|
||||
text
|
||||
};
|
||||
vec![
|
||||
(
|
||||
root.join("web/workspace/src/lib/generated/error-contract.ts"),
|
||||
normalize_typescript(types),
|
||||
),
|
||||
(
|
||||
root.join("openapi/failure.schema.json"),
|
||||
render(error_contract::schema::failure_schema::<Value>()),
|
||||
),
|
||||
(
|
||||
root.join("openapi/problem-details.schema.json"),
|
||||
render(error_contract::schema::problem_schema::<Value>()),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Generates the Browser receive validator paired with TypeScript declarations.
|
||||
///
|
||||
/// Normalizes schemas reachable from [`SubscriptionFrame`] to Rust's actual wire shape.
|
||||
@@ -597,6 +635,18 @@ mod tests {
|
||||
close_object_shapes(&mut unsupported);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_failure_artifacts_are_current() {
|
||||
for (path, expected) in generated_failure_contract_artifacts() {
|
||||
let actual = std::fs::read_to_string(&path)
|
||||
.unwrap_or_else(|error| panic!("{}: {error}", path.display()));
|
||||
assert_eq!(
|
||||
actual, expected,
|
||||
"regenerate with protocol generate_typescript"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_protocol_types_are_current() {
|
||||
let expected = generated_protocol_types();
|
||||
|
||||
@@ -8,7 +8,9 @@ license.workspace = true
|
||||
[dependencies]
|
||||
api-macros = { workspace = true, features = ["reqwest", "axum"] }
|
||||
manifest.workspace = true
|
||||
plugin-plan = { path = "../plugin-plan" }
|
||||
protocol.workspace = true
|
||||
session-store.workspace = true
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
workdir.workspace = true
|
||||
|
||||
|
||||
+644
-10
@@ -86,6 +86,8 @@ pub enum WorkerRestoreState {
|
||||
Accepted,
|
||||
Rejected,
|
||||
RolledBack,
|
||||
/// A later Stop superseded this restore; neither rollback nor prior Accepted is asserted.
|
||||
Superseded,
|
||||
ReconciliationRequired,
|
||||
}
|
||||
|
||||
@@ -406,6 +408,9 @@ pub enum WorkerInputKind {
|
||||
pub struct WorkerInput {
|
||||
pub kind: WorkerInputKind,
|
||||
pub content: String,
|
||||
/// Provenance stamped by the authenticated Backend, never by public/model input.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source: Option<protocol::AuthenticatedInputSource>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub submission_request_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -425,6 +430,19 @@ pub struct BackendJobExecutionBinding {
|
||||
pub input_digest: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subjektiv_consolidation: bool,
|
||||
#[serde(default)]
|
||||
pub subjektiv_journal: bool,
|
||||
}
|
||||
|
||||
/// Server-authored immutable launch input, retained unchanged across restoration.
|
||||
/// A snapshot fixes selection and ceilings; each preparation and invocation must
|
||||
/// still consult the current owner. No credential or cache location is serialized.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PluginExecutionSnapshot {
|
||||
pub plan_id: String,
|
||||
pub fingerprint: String,
|
||||
pub plan: plugin_plan::WorkerPluginExecutionPlan,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
@@ -443,6 +461,9 @@ pub struct CreateWorkerRequest {
|
||||
pub workdir_attachment_requests: Vec<WorkingDirectoryAttachmentRequest>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub workdir_attachments: Vec<WorkingDirectoryAttachmentClaim>,
|
||||
/// Enables the optional Runtime-local peer observation provider, not operation
|
||||
/// authority. Scoped Backend observation follows the Worker Feature and live
|
||||
/// operation/control checks, including when this saved flag is false.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub worker_observation_enabled: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
@@ -451,6 +472,8 @@ pub struct CreateWorkerRequest {
|
||||
pub workspace_api: Option<WorkspaceApiRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_execution: Option<PluginExecutionSnapshot>,
|
||||
/// Backend-attested subject or consolidation attachment. Profile policy alone
|
||||
/// never activates subjektiv for an ordinary Workspace Worker.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
@@ -516,9 +539,10 @@ pub struct WorkerRestorePreparation {
|
||||
pub workspace_api: Option<WorkspaceApiRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workdir_attachments: Option<Vec<LogicalWorkdirAttachment>>,
|
||||
/// Retained in saved Restore intents; Restore does not apply Repository access.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub repository_access: Vec<WorkingDirectoryRepositoryAccessRequest>,
|
||||
/// Read-only references resolved by Workspace only after this operation owns admission.
|
||||
/// Retained references from saved resource preparation, not Restore prerequisites.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub repository_access_workdirs: Vec<RepositoryAccessWorkdirReference>,
|
||||
}
|
||||
@@ -599,9 +623,29 @@ pub struct WorkerDeleteResponse {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerLifecycleRequest {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reason: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub stop_intent: Option<WorkerStopIntent>,
|
||||
}
|
||||
|
||||
/// Fences a normal Worker stop to the observed lifecycle, not a Subject ledger.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerStopIntent {
|
||||
pub expected_observation_token: String,
|
||||
pub request_id: String,
|
||||
}
|
||||
|
||||
/// Historical stop evidence. Stopped does not describe the current execution.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum WorkerStopOutcome {
|
||||
NotFound,
|
||||
Pending,
|
||||
Stopped { ack: WorkerLifecycleAck },
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
|
||||
@@ -609,6 +653,10 @@ pub struct EmptyObjectRequest {}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
pub struct WorkerLifecycleAck {
|
||||
/// Historical typed stop receipt, not a fresh stop of the current execution.
|
||||
/// Consumers must not repeat cleanup of current resources when this is true.
|
||||
#[serde(default)]
|
||||
pub replayed: bool,
|
||||
pub worker_ref: WorkerRef,
|
||||
pub status: WorkerStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -712,6 +760,8 @@ pub struct WorkerSessionArchiveManifest {
|
||||
pub source_runtime_id: String,
|
||||
pub source_worker_id: WorkerId,
|
||||
pub source_session_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_active_segment_id: Option<String>,
|
||||
pub segment_ids: Vec<String>,
|
||||
pub source_created_at: String,
|
||||
pub removed_at: String,
|
||||
@@ -787,6 +837,146 @@ pub enum SessionPublicSource {
|
||||
Archived { archive_id: String },
|
||||
}
|
||||
|
||||
/// Canonical Session metadata, distinct from Memory's evidence/public-index projection.
|
||||
/// `origin_json` is the exact serialized saved origin; it is not model-authored evidence.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct SessionMaterialDescriptor {
|
||||
pub segment_id: String,
|
||||
pub entry_ref: String,
|
||||
pub canonical_order: u64,
|
||||
pub origin_json: String,
|
||||
pub sha256: String,
|
||||
pub total_bytes: u64,
|
||||
}
|
||||
|
||||
/// Explicit Host erasure, distinct from ordinary retention policy or pin release.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SessionMaterialEraseRequest {
|
||||
pub workspace_id: String,
|
||||
pub source: SessionPublicSource,
|
||||
pub expected_session_id: String,
|
||||
}
|
||||
|
||||
/// Acknowledges the permanent fence and physical erasure of Session originals,
|
||||
/// including archived diagnostics bound to the same removal operations. Historical
|
||||
/// retention receipts remain immutable; their old retained flags are not current availability.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SessionMaterialEraseResponse {
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SessionMaterialPinRequest {
|
||||
pub workspace_id: String,
|
||||
pub source: SessionPublicSource,
|
||||
pub expected_session_id: String,
|
||||
pub pin_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SessionMaterialPinResponse {
|
||||
pub session_id: String,
|
||||
pub pin_id: String,
|
||||
}
|
||||
|
||||
/// One body-bearing immutable slice. The byte budget includes slice metadata,
|
||||
/// but not the enclosing availability enum or the caller's durable Job envelope.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SessionRecordingCaptureRequest {
|
||||
pub workspace_id: String,
|
||||
pub source: SessionPublicSource,
|
||||
pub expected_session_id: String,
|
||||
pub start: session_store::capture::RecordingPosition,
|
||||
pub max_entries: usize,
|
||||
pub max_serialized_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "availability", rename_all = "snake_case")]
|
||||
pub enum SessionRecordingCaptureAvailability {
|
||||
NotMaterialized {
|
||||
session_id: String,
|
||||
},
|
||||
Captured {
|
||||
slice: session_store::capture::RecordingCaptureSlice,
|
||||
},
|
||||
Unavailable {
|
||||
reason: SessionPublicUnavailableReason,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SessionMaterialCatalogRequest {
|
||||
pub workspace_id: String,
|
||||
pub source: SessionPublicSource,
|
||||
pub expected_session_id: String,
|
||||
pub expected_catalog_sha256: Option<String>,
|
||||
pub offset: u64,
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SessionMaterialCatalogPage {
|
||||
pub session_id: String,
|
||||
pub active_segment_id: String,
|
||||
pub catalog_sha256: String,
|
||||
pub descriptors: Vec<SessionMaterialDescriptor>,
|
||||
pub next_offset: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SessionMaterialReadRequest {
|
||||
pub workspace_id: String,
|
||||
pub source: SessionPublicSource,
|
||||
pub expected_session_id: String,
|
||||
/// Compared with a freshly resolved saved descriptor, never adopted as evidence.
|
||||
pub expected_descriptor: SessionMaterialDescriptor,
|
||||
pub byte_offset: u64,
|
||||
pub max_content_bytes: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct SessionMaterialReadPage {
|
||||
pub descriptor: SessionMaterialDescriptor,
|
||||
pub content: String,
|
||||
pub next_offset: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "availability", rename_all = "snake_case")]
|
||||
pub enum SessionMaterialCatalogAvailability {
|
||||
/// The saved active pointer is pending and storage proves no Session material
|
||||
/// has ever been published. Missing/corrupt materialized sources are not empty.
|
||||
NotMaterialized {
|
||||
session_id: String,
|
||||
},
|
||||
Page {
|
||||
page: SessionMaterialCatalogPage,
|
||||
},
|
||||
Unavailable {
|
||||
reason: SessionPublicUnavailableReason,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "availability", rename_all = "snake_case")]
|
||||
pub enum SessionMaterialReadAvailability {
|
||||
Page {
|
||||
page: SessionMaterialReadPage,
|
||||
},
|
||||
Unavailable {
|
||||
reason: SessionPublicUnavailableReason,
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionPublicEntryKind {
|
||||
@@ -988,8 +1178,136 @@ pub enum WorkerSessionAvailability {
|
||||
},
|
||||
}
|
||||
|
||||
/// Host-owned Subject release operation. A reservation has no timeout: uncertainty retains
|
||||
/// occupancy and mutation exclusion until the same operation is explicitly reconciled.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjectReleaseRequest {
|
||||
pub workspace_id: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub binding_id: String,
|
||||
pub operation_id: String,
|
||||
pub action: SubjectReleaseAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SubjectReleaseAction {
|
||||
Reserve,
|
||||
Disconnect,
|
||||
Cancel,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SubjectReleaseState {
|
||||
Reserved,
|
||||
Disconnected,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjectReleaseResponse {
|
||||
pub operation_id: String,
|
||||
pub binding_id: String,
|
||||
pub state: SubjectReleaseState,
|
||||
}
|
||||
|
||||
/// Irreversibly fence an unpublished failed Subject create. Exact retries must retain
|
||||
/// all four fields; this is distinct from releasing an existing Subject Controller.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjectCreateRecoveryRequest {
|
||||
pub workspace_id: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub create_fingerprint: String,
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
/// Durable admission-fence proof plus freshly confirmed execution absence.
|
||||
/// An error (including a lost response) is never evidence of absence.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjectCreateRecoveryResponse {
|
||||
pub workspace_id: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub create_fingerprint: String,
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
/// Host requests this only after durably fencing the Subject in Backend authority.
|
||||
/// Permanently exclude the Worker from creation and restoration, and confirm actual
|
||||
/// execution shutdown. This does not delete Worker or Session records.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjectDeleteWorkerRequest {
|
||||
pub workspace_id: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub create_fingerprint: String,
|
||||
pub operation_id: String,
|
||||
}
|
||||
|
||||
/// Durable admission exclusion plus confirmed execution shutdown/absence. Errors,
|
||||
/// timeouts and lost responses are unknown outcomes; retry the same full request.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjectDeleteWorkerResponse {
|
||||
pub workspace_id: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub create_fingerprint: String,
|
||||
pub operation_id: String,
|
||||
/// The canonical live Worker/Session/diagnostic aggregate is physically absent
|
||||
/// under the permanent creation fence. False is not a deletion receipt.
|
||||
pub originals_absent: bool,
|
||||
}
|
||||
|
||||
/// Additional erasure of a Subject Job's newly reconciled removal copies.
|
||||
/// Both identities are exact saved requests; this never changes the old receipt.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjectJobRemovalPurgeRequest {
|
||||
pub deletion: SubjectDeleteWorkerRequest,
|
||||
pub removal: WorkerRetentionExecutionRequest,
|
||||
}
|
||||
|
||||
/// Confirms current physical erasure under the permanent deletion fence.
|
||||
/// Operation identity belongs to `removal`, not the Subject deletion operation.
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SubjectJobRemovalPurgeResponse {
|
||||
pub workspace_id: String,
|
||||
pub worker_id: WorkerId,
|
||||
pub operation_id: String,
|
||||
pub input_fingerprint: String,
|
||||
}
|
||||
|
||||
#[api(reqwest, axum)]
|
||||
pub trait RuntimeApi {
|
||||
#[post("/v1/subject-job-removal-purge", status = 200, error_status = 400)]
|
||||
async fn purge_subject_job_removal(
|
||||
&self,
|
||||
#[body] request: SubjectJobRemovalPurgeRequest,
|
||||
) -> Result<SubjectJobRemovalPurgeResponse, RuntimeApiError>;
|
||||
|
||||
#[post("/v1/subject-delete-worker", status = 200, error_status = 400)]
|
||||
async fn subject_delete_worker(
|
||||
&self,
|
||||
#[body] request: SubjectDeleteWorkerRequest,
|
||||
) -> Result<SubjectDeleteWorkerResponse, RuntimeApiError>;
|
||||
|
||||
#[post("/v1/subject-create-recovery", status = 200, error_status = 400)]
|
||||
async fn recover_subject_create(
|
||||
&self,
|
||||
#[body] request: SubjectCreateRecoveryRequest,
|
||||
) -> Result<SubjectCreateRecoveryResponse, RuntimeApiError>;
|
||||
|
||||
#[post("/v1/subject-release", status = 200, error_status = 400)]
|
||||
async fn subject_release(
|
||||
&self,
|
||||
#[body] request: SubjectReleaseRequest,
|
||||
) -> Result<SubjectReleaseResponse, RuntimeApiError>;
|
||||
|
||||
#[get("/v1/ping", status = 200, error_status = 400)]
|
||||
async fn ping(
|
||||
&self,
|
||||
@@ -1029,6 +1347,62 @@ pub trait RuntimeApi {
|
||||
#[query] request: WorkerSessionHistoryRequest,
|
||||
) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError>;
|
||||
|
||||
#[post(
|
||||
"/v1/session-public/material/retention/erase",
|
||||
status = 200,
|
||||
error_status = 400
|
||||
)]
|
||||
async fn session_material_erase(
|
||||
&self,
|
||||
#[body] request: SessionMaterialEraseRequest,
|
||||
) -> Result<SessionMaterialEraseResponse, RuntimeApiError>;
|
||||
|
||||
#[post(
|
||||
"/v1/session-public/material/retention/pin",
|
||||
status = 200,
|
||||
error_status = 400
|
||||
)]
|
||||
async fn session_material_pin(
|
||||
&self,
|
||||
#[body] request: SessionMaterialPinRequest,
|
||||
) -> Result<SessionMaterialPinResponse, RuntimeApiError>;
|
||||
|
||||
#[post(
|
||||
"/v1/session-public/material/retention/release",
|
||||
status = 200,
|
||||
error_status = 400
|
||||
)]
|
||||
async fn session_material_release(
|
||||
&self,
|
||||
#[body] request: SessionMaterialPinRequest,
|
||||
) -> Result<SessionMaterialPinResponse, RuntimeApiError>;
|
||||
|
||||
#[post(
|
||||
"/v1/session-public/recording-capture",
|
||||
status = 200,
|
||||
error_status = 400
|
||||
)]
|
||||
async fn session_recording_capture(
|
||||
&self,
|
||||
#[body] request: SessionRecordingCaptureRequest,
|
||||
) -> Result<SessionRecordingCaptureAvailability, RuntimeApiError>;
|
||||
|
||||
#[post(
|
||||
"/v1/session-public/material/catalog",
|
||||
status = 200,
|
||||
error_status = 400
|
||||
)]
|
||||
async fn session_material_catalog(
|
||||
&self,
|
||||
#[body] request: SessionMaterialCatalogRequest,
|
||||
) -> Result<SessionMaterialCatalogAvailability, RuntimeApiError>;
|
||||
|
||||
#[post("/v1/session-public/material/read", status = 200, error_status = 400)]
|
||||
async fn session_material_read(
|
||||
&self,
|
||||
#[body] request: SessionMaterialReadRequest,
|
||||
) -> Result<SessionMaterialReadAvailability, RuntimeApiError>;
|
||||
|
||||
#[post("/v1/session-public/search", status = 200, error_status = 400)]
|
||||
async fn session_public_search(
|
||||
&self,
|
||||
@@ -1060,13 +1434,29 @@ pub trait RuntimeApi {
|
||||
#[body] input: WorkerInput,
|
||||
) -> Result<WorkerInputResponse, RuntimeApiError>;
|
||||
|
||||
#[post("/v1/workers/{worker_id}/stop", status = 200, error_status = 400)]
|
||||
#[post("/v1/workers/{worker_id}/stop", status = 200, error_status = 400, additional_error_statuses = [409])]
|
||||
async fn stop_worker(
|
||||
&self,
|
||||
#[path] worker_id: String,
|
||||
#[body] request: WorkerLifecycleRequest,
|
||||
) -> Result<WorkerLifecycleResponse, RuntimeApiError>;
|
||||
|
||||
/// Admit the same fenced stop without backend cleanup; requires stop_intent.
|
||||
#[post("/v1/workers/{worker_id}/stop/admit", status = 200, error_status = 400, additional_error_statuses = [409])]
|
||||
async fn coordinate_worker_stop(
|
||||
&self,
|
||||
#[path] worker_id: String,
|
||||
#[body] request: WorkerLifecycleRequest,
|
||||
) -> Result<WorkerStopOutcome, RuntimeApiError>;
|
||||
|
||||
/// Read-only exact-identity lookup; never initiates backend stop cleanup.
|
||||
#[post("/v1/workers/{worker_id}/stop/outcome", status = 200, error_status = 400, additional_error_statuses = [409])]
|
||||
async fn worker_stop_outcome(
|
||||
&self,
|
||||
#[path] worker_id: String,
|
||||
#[body] request: WorkerLifecycleRequest,
|
||||
) -> Result<WorkerStopOutcome, RuntimeApiError>;
|
||||
|
||||
#[post("/v1/workers/{worker_id}/cancel", status = 200, error_status = 400)]
|
||||
async fn cancel_worker(
|
||||
&self,
|
||||
@@ -1277,6 +1667,87 @@ pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn trusted_notify_source_round_trips_and_omitted_source_defaults_to_none() {
|
||||
let mut value = serde_json::json!({
|
||||
"kind": "notify", "content": "message", "submission_request_id": "stable-id"
|
||||
});
|
||||
let legacy: WorkerInput = serde_json::from_value(value.clone()).unwrap();
|
||||
assert!(legacy.source.is_none());
|
||||
assert_eq!(serde_json::to_value(legacy).unwrap(), value);
|
||||
for source in [
|
||||
protocol::AuthenticatedInputSource::Account {
|
||||
account_id: "account-a".into(),
|
||||
},
|
||||
protocol::AuthenticatedInputSource::Worker {
|
||||
runtime_id: "runtime-a".into(),
|
||||
worker_id: "worker-a".into(),
|
||||
},
|
||||
] {
|
||||
value["source"] = serde_json::to_value(&source).unwrap();
|
||||
let input: WorkerInput = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(input.source, Some(source));
|
||||
assert_eq!(serde_json::to_value(input).unwrap(), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_job_journal_grant_defaults_off_and_does_not_enable_memory_grant() {
|
||||
let legacy = serde_json::json!({"job_id":"job", "attempt_id":"attempt", "subjektiv_consolidation":true});
|
||||
let binding: BackendJobExecutionBinding = serde_json::from_value(legacy).unwrap();
|
||||
assert!(binding.subjektiv_consolidation);
|
||||
assert!(!binding.subjektiv_journal);
|
||||
let journal =
|
||||
serde_json::json!({"job_id":"job", "attempt_id":"attempt", "subjektiv_journal":true});
|
||||
let binding: BackendJobExecutionBinding = serde_json::from_value(journal.clone()).unwrap();
|
||||
assert!(binding.subjektiv_journal);
|
||||
assert!(!binding.subjektiv_consolidation);
|
||||
let roundtrip: BackendJobExecutionBinding =
|
||||
serde_json::from_value(serde_json::to_value(&binding).unwrap()).unwrap();
|
||||
assert_eq!(roundtrip, binding);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_ack_legacy_default_and_replay_marker_round_trip() {
|
||||
let mut value = serde_json::json!({
|
||||
"worker_ref": {"worker_id": WorkerId::now_v7()}, "status": "stopped"
|
||||
});
|
||||
let legacy: WorkerLifecycleAck = serde_json::from_value(value.clone()).unwrap();
|
||||
assert!(!legacy.replayed);
|
||||
value["replayed"] = serde_json::json!(false);
|
||||
assert_eq!(serde_json::to_value(legacy).unwrap(), value);
|
||||
value["replayed"] = serde_json::json!(true);
|
||||
let replay: WorkerLifecycleAck = serde_json::from_value(value.clone()).unwrap();
|
||||
assert!(replay.replayed);
|
||||
assert_eq!(serde_json::to_value(replay).unwrap(), value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_stop_intent_defaults_and_strict_external_fields() {
|
||||
for value in [
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({"reason": "cancel"}),
|
||||
] {
|
||||
let request: WorkerLifecycleRequest = serde_json::from_value(value.clone()).unwrap();
|
||||
assert!(request.stop_intent.is_none());
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), value);
|
||||
}
|
||||
let value = serde_json::json!({"reason":"stop", "stop_intent": {
|
||||
"expected_observation_token":"observed", "request_id":"same-request"
|
||||
}});
|
||||
let request: WorkerLifecycleRequest = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(request).unwrap(), value);
|
||||
for invalid in [
|
||||
serde_json::json!({"unknown":true}),
|
||||
serde_json::json!({"stop_intent":{}}),
|
||||
serde_json::json!({"stop_intent":{"request_id":"r"}}),
|
||||
serde_json::json!({"stop_intent":{"expected_observation_token":"t"}}),
|
||||
serde_json::json!({"stop_intent":{"expected_observation_token":"t", "request_id":"r", "unknown":true}}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<WorkerLifecycleRequest>(invalid).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_request_is_mandatory_and_conflict_is_not_an_operation_state() {
|
||||
for invalid in [
|
||||
@@ -1291,13 +1762,24 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
assert!(request.preparation.is_none());
|
||||
for state in [
|
||||
"accepted",
|
||||
"rejected",
|
||||
"rolled_back",
|
||||
"reconciliation_required",
|
||||
for (state, wire) in [
|
||||
(WorkerRestoreState::Accepted, "accepted"),
|
||||
(WorkerRestoreState::Rejected, "rejected"),
|
||||
(WorkerRestoreState::RolledBack, "rolled_back"),
|
||||
(WorkerRestoreState::Superseded, "superseded"),
|
||||
(
|
||||
WorkerRestoreState::ReconciliationRequired,
|
||||
"reconciliation_required",
|
||||
),
|
||||
] {
|
||||
assert!(serde_json::from_value::<WorkerRestoreState>(serde_json::json!(state)).is_ok());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<WorkerRestoreState>(serde_json::json!(wire)).unwrap(),
|
||||
state
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(state).unwrap(),
|
||||
serde_json::json!(wire)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<WorkerRestoreState>(serde_json::json!(
|
||||
@@ -1442,6 +1924,91 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
async fn session_material_erase(
|
||||
&self,
|
||||
_request: SessionMaterialEraseRequest,
|
||||
) -> Result<SessionMaterialEraseResponse, RuntimeApiError> {
|
||||
Err(test_error(501))
|
||||
}
|
||||
|
||||
async fn session_material_pin(
|
||||
&self,
|
||||
_request: SessionMaterialPinRequest,
|
||||
) -> Result<SessionMaterialPinResponse, RuntimeApiError> {
|
||||
Err(test_error(501))
|
||||
}
|
||||
async fn purge_subject_job_removal(
|
||||
&self,
|
||||
request: SubjectJobRemovalPurgeRequest,
|
||||
) -> Result<SubjectJobRemovalPurgeResponse, RuntimeApiError> {
|
||||
Ok(SubjectJobRemovalPurgeResponse {
|
||||
workspace_id: request.removal.workspace_id,
|
||||
worker_id: request.removal.worker_id,
|
||||
operation_id: request.removal.operation_id,
|
||||
input_fingerprint: request.removal.input_fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
async fn subject_delete_worker(
|
||||
&self,
|
||||
request: SubjectDeleteWorkerRequest,
|
||||
) -> Result<SubjectDeleteWorkerResponse, RuntimeApiError> {
|
||||
Ok(SubjectDeleteWorkerResponse {
|
||||
originals_absent: true,
|
||||
workspace_id: request.workspace_id,
|
||||
worker_id: request.worker_id,
|
||||
create_fingerprint: request.create_fingerprint,
|
||||
operation_id: request.operation_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn recover_subject_create(
|
||||
&self,
|
||||
request: SubjectCreateRecoveryRequest,
|
||||
) -> Result<SubjectCreateRecoveryResponse, RuntimeApiError> {
|
||||
Ok(SubjectCreateRecoveryResponse {
|
||||
workspace_id: request.workspace_id,
|
||||
worker_id: request.worker_id,
|
||||
create_fingerprint: request.create_fingerprint,
|
||||
operation_id: request.operation_id,
|
||||
})
|
||||
}
|
||||
|
||||
async fn subject_release(
|
||||
&self,
|
||||
_request: SubjectReleaseRequest,
|
||||
) -> Result<SubjectReleaseResponse, RuntimeApiError> {
|
||||
Err(test_error(501))
|
||||
}
|
||||
|
||||
async fn session_material_release(
|
||||
&self,
|
||||
_request: SessionMaterialPinRequest,
|
||||
) -> Result<SessionMaterialPinResponse, RuntimeApiError> {
|
||||
Err(test_error(501))
|
||||
}
|
||||
|
||||
async fn session_recording_capture(
|
||||
&self,
|
||||
_request: SessionRecordingCaptureRequest,
|
||||
) -> Result<SessionRecordingCaptureAvailability, RuntimeApiError> {
|
||||
Err(test_error(501))
|
||||
}
|
||||
|
||||
async fn session_material_catalog(
|
||||
&self,
|
||||
_request: SessionMaterialCatalogRequest,
|
||||
) -> Result<SessionMaterialCatalogAvailability, RuntimeApiError> {
|
||||
Err(test_error(501))
|
||||
}
|
||||
|
||||
async fn session_material_read(
|
||||
&self,
|
||||
_request: SessionMaterialReadRequest,
|
||||
) -> Result<SessionMaterialReadAvailability, RuntimeApiError> {
|
||||
Err(test_error(501))
|
||||
}
|
||||
|
||||
async fn session_public_search(
|
||||
&self,
|
||||
_request: SessionPublicSearchRequest,
|
||||
@@ -1488,6 +2055,20 @@ mod tests {
|
||||
) -> Result<WorkerLifecycleResponse, RuntimeApiError> {
|
||||
Err(test_error(501))
|
||||
}
|
||||
async fn coordinate_worker_stop(
|
||||
&self,
|
||||
_worker_id: String,
|
||||
_request: WorkerLifecycleRequest,
|
||||
) -> Result<WorkerStopOutcome, RuntimeApiError> {
|
||||
Err(test_error(501))
|
||||
}
|
||||
async fn worker_stop_outcome(
|
||||
&self,
|
||||
_worker_id: String,
|
||||
_request: WorkerLifecycleRequest,
|
||||
) -> Result<WorkerStopOutcome, RuntimeApiError> {
|
||||
Err(test_error(501))
|
||||
}
|
||||
async fn cancel_worker(
|
||||
&self,
|
||||
_worker_id: String,
|
||||
@@ -1559,6 +2140,60 @@ mod tests {
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
let recovery = SubjectCreateRecoveryRequest {
|
||||
workspace_id: "workspace-a".into(),
|
||||
worker_id: WorkerId::now_v7(),
|
||||
create_fingerprint: "failed-create-fingerprint".into(),
|
||||
operation_id: "recovery-1".into(),
|
||||
};
|
||||
let response = client
|
||||
.recover_subject_create(recovery.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.workspace_id, recovery.workspace_id);
|
||||
assert_eq!(response.worker_id, recovery.worker_id);
|
||||
assert_eq!(response.create_fingerprint, recovery.create_fingerprint);
|
||||
assert_eq!(response.operation_id, recovery.operation_id);
|
||||
|
||||
let deletion = SubjectDeleteWorkerRequest {
|
||||
workspace_id: "workspace-a".into(),
|
||||
worker_id: WorkerId::now_v7(),
|
||||
create_fingerprint: "deleted-create".into(),
|
||||
operation_id: "delete-1".into(),
|
||||
};
|
||||
let receipt = client
|
||||
.subject_delete_worker(deletion.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let mut expected = serde_json::to_value(&deletion).unwrap();
|
||||
expected["originals_absent"] = true.into();
|
||||
assert_eq!(serde_json::to_value(receipt).unwrap(), expected);
|
||||
let purge = SubjectJobRemovalPurgeRequest {
|
||||
removal: WorkerRetentionExecutionRequest {
|
||||
operation_id: "old-removal".into(),
|
||||
input_fingerprint: "old-removal-fingerprint".into(),
|
||||
archive_id: Some("old-archive".into()),
|
||||
workspace_id: deletion.workspace_id.clone(),
|
||||
source_runtime_id: "runtime-a".into(),
|
||||
worker_id: deletion.worker_id,
|
||||
source_created_at: "2026-01-01T00:00:00Z".into(),
|
||||
removed_at: "2026-01-02T00:00:00Z".into(),
|
||||
effective_profile: None,
|
||||
retention_class: None,
|
||||
policy_id: "old-policy".into(),
|
||||
session_disposition: SessionDisposition::Archive,
|
||||
diagnostics_disposition: DiagnosticsDisposition::Retain,
|
||||
},
|
||||
deletion,
|
||||
};
|
||||
let receipt = client
|
||||
.purge_subject_job_removal(purge.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(receipt.workspace_id, purge.removal.workspace_id);
|
||||
assert_eq!(receipt.worker_id, purge.removal.worker_id);
|
||||
assert_eq!(receipt.operation_id, purge.removal.operation_id);
|
||||
assert_eq!(receipt.input_fingerprint, purge.removal.input_fingerprint);
|
||||
let summary = client.runtime_summary().await.unwrap();
|
||||
assert_eq!(summary.runtime.display_name.as_deref(), Some("round-trip"));
|
||||
let workers = client
|
||||
@@ -1603,9 +2238,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contract_inventory_is_complete_and_unique() {
|
||||
fn contract_inventory_has_unique_routes_under_api_base_path() {
|
||||
let operations = RuntimeApiMetadata::OPERATIONS;
|
||||
assert_eq!(operations.len(), 20);
|
||||
let mut routes = operations
|
||||
.iter()
|
||||
.map(|operation| (format!("{:?}", operation.method), operation.path))
|
||||
|
||||
@@ -12,6 +12,7 @@ typescript = ["dep:ts-rs", "protocol/typescript"]
|
||||
[dependencies]
|
||||
api-macros = { workspace = true, features = ["reqwest", "axum", "openapi"] }
|
||||
memory.workspace = true
|
||||
plugin-plan.workspace = true
|
||||
merge-request.workspace = true
|
||||
protocol = { workspace = true, features = ["json-schema"] }
|
||||
runtime-api.workspace = true
|
||||
@@ -21,10 +22,12 @@ serde_json.workspace = true
|
||||
sha2.workspace = true
|
||||
ticket.workspace = true
|
||||
ts-rs = { version = "12.0.1", optional = true }
|
||||
url.workspace = true
|
||||
webauthn-rs-proto = "0.5.5"
|
||||
workdir.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
[[example]]
|
||||
@@ -40,6 +43,14 @@ name = "generate_worker_launch_api_types"
|
||||
[[example]]
|
||||
name = "generate_companion_api_types"
|
||||
|
||||
[[example]]
|
||||
name = "generate_chat_api_types"
|
||||
required-features = ["typescript"]
|
||||
|
||||
[[example]]
|
||||
name = "generate_subject_body_api_types"
|
||||
required-features = ["typescript"]
|
||||
|
||||
[[example]]
|
||||
name = "generate_memory_api_types"
|
||||
required-features = ["typescript"]
|
||||
@@ -59,3 +70,7 @@ required-features = ["typescript"]
|
||||
[[example]]
|
||||
name = "generate_drive_api_types"
|
||||
required-features = ["typescript"]
|
||||
|
||||
[[example]]
|
||||
name = "generate_plugin_api_types"
|
||||
required-features = ["typescript"]
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::chat_api_typescript())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::plugin_api_typescript())
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
fn main() {
|
||||
print!(
|
||||
"{}",
|
||||
server_api::normalized_typescript(server_api::subject_body_api_typescript())
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,951 @@
|
||||
//! Management metadata over the existing policy, not an authorization source.
|
||||
//!
|
||||
//! Only the management replacement path validates against this catalog. Historical
|
||||
//! deserialization and execution grants retain their exact spelling and scope. New
|
||||
//! operations require an explicit catalog entry; dotted prefixes grant nothing.
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "catalog_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PermissionResourceKind {
|
||||
PluginInstallation,
|
||||
Job,
|
||||
Ticket,
|
||||
Objective,
|
||||
MergeRequest,
|
||||
Subject,
|
||||
DriveEntry,
|
||||
DriveRequest,
|
||||
Repository,
|
||||
WorkdirAttachment,
|
||||
WorkspaceConfig,
|
||||
Network,
|
||||
SubWorker,
|
||||
Feature,
|
||||
Workspace,
|
||||
Workdir,
|
||||
Worker,
|
||||
WorkerOperation,
|
||||
}
|
||||
|
||||
impl PermissionResource {
|
||||
pub fn kind(&self) -> PermissionResourceKind {
|
||||
use PermissionResourceKind as K;
|
||||
match self {
|
||||
Self::PluginInstallation { .. } => K::PluginInstallation,
|
||||
Self::Job { .. } => K::Job,
|
||||
Self::Ticket { .. } => K::Ticket,
|
||||
Self::Objective { .. } => K::Objective,
|
||||
Self::MergeRequest { .. } => K::MergeRequest,
|
||||
Self::Subject { .. } => K::Subject,
|
||||
Self::DriveEntry { .. } => K::DriveEntry,
|
||||
Self::DriveRequest { .. } => K::DriveRequest,
|
||||
Self::Repository { .. } => K::Repository,
|
||||
Self::WorkdirAttachment { .. } => K::WorkdirAttachment,
|
||||
Self::WorkspaceConfig { .. } => K::WorkspaceConfig,
|
||||
Self::Network { .. } => K::Network,
|
||||
Self::SubWorker { .. } => K::SubWorker,
|
||||
Self::Feature => K::Feature,
|
||||
Self::Workspace { .. } => K::Workspace,
|
||||
Self::Workdir { .. } => K::Workdir,
|
||||
Self::Worker { .. } => K::Worker,
|
||||
Self::WorkerOperation { .. } => K::WorkerOperation,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PermissionOperationAvailability {
|
||||
Available,
|
||||
Unavailable { reason: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PermissionOperationDescriptor {
|
||||
pub operation: String,
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
/// Accepted management input kinds, not a claim that a target exists or is usable.
|
||||
pub resource_kinds: Vec<PermissionResourceKind>,
|
||||
pub availability: PermissionOperationAvailability,
|
||||
}
|
||||
|
||||
/// Available means the operation/resource contract is implemented, not that the
|
||||
/// current Profile, provider, target, assignment or Job permits its execution.
|
||||
/// Body execution and delegation ceilings use the same descriptors, independently.
|
||||
pub fn subject_permission_operations() -> Vec<PermissionOperationDescriptor> {
|
||||
use PermissionResourceKind as K;
|
||||
let mut catalog = Vec::new();
|
||||
let mut add = |kind, entries: &[(&str, &str, &str)]| {
|
||||
catalog.extend(entries.iter().map(|(operation, label, description)| {
|
||||
PermissionOperationDescriptor {
|
||||
operation: (*operation).into(),
|
||||
label: (*label).into(),
|
||||
description: (*description).into(),
|
||||
resource_kinds: vec![kind],
|
||||
availability: PermissionOperationAvailability::Available,
|
||||
}
|
||||
}));
|
||||
};
|
||||
add(
|
||||
K::Workdir,
|
||||
&[
|
||||
(
|
||||
"filesystem.read",
|
||||
"Read files",
|
||||
"Read files and enumerate directories within the selected path and recursive scope; provider scope still applies.",
|
||||
),
|
||||
(
|
||||
"filesystem.write",
|
||||
"Edit files",
|
||||
"Create or edit files within the selected path and recursive scope; does not permit commands.",
|
||||
),
|
||||
(
|
||||
"bash.execute",
|
||||
"Run commands",
|
||||
"Requires the whole Workdir: path '.' and recursive true. A shell is not confined to a subdirectory. Explicit provider command authority is also required.",
|
||||
),
|
||||
(
|
||||
"workdir.read",
|
||||
"Inspect Workdir",
|
||||
"Read selected Workdir metadata; does not grant file access.",
|
||||
),
|
||||
(
|
||||
"workdir.attach",
|
||||
"Attach Workdir",
|
||||
"Attach the selected Workdir subject to existing provider and attachment constraints.",
|
||||
),
|
||||
(
|
||||
"workdir.delete",
|
||||
"Delete Workdir",
|
||||
"Delete the selected Workdir only if existing occupancy and retention conditions permit it.",
|
||||
),
|
||||
],
|
||||
);
|
||||
add(
|
||||
K::Network,
|
||||
&[
|
||||
(
|
||||
"web.fetch",
|
||||
"Fetch web page",
|
||||
"Fetch this exact HTTP(S) URL. Each redirect needs its own permission; private/local hosts remain denied.",
|
||||
),
|
||||
(
|
||||
"web.search",
|
||||
"Search web",
|
||||
"Contact this exact search provider endpoint; credentials and network restrictions still apply.",
|
||||
),
|
||||
],
|
||||
);
|
||||
add(
|
||||
K::Workspace,
|
||||
&[
|
||||
(
|
||||
"ticket.create",
|
||||
"Create Tickets",
|
||||
"Create open Tickets in this Workspace; no read or edit rights to existing Tickets are implied.",
|
||||
),
|
||||
(
|
||||
"ticket.query",
|
||||
"Search Tickets",
|
||||
"Query the Workspace Ticket collection.",
|
||||
),
|
||||
(
|
||||
"ticket.list",
|
||||
"List Tickets",
|
||||
"List the Workspace Ticket collection.",
|
||||
),
|
||||
(
|
||||
"ticket.doctor",
|
||||
"Diagnose Tickets",
|
||||
"Inspect Workspace Ticket diagnostics.",
|
||||
),
|
||||
(
|
||||
"ticket.intake.default_body",
|
||||
"Read intake template",
|
||||
"Read the default intake transition body.",
|
||||
),
|
||||
(
|
||||
"ticket.tag.list",
|
||||
"List classification tags",
|
||||
"Read Workspace classification tags.",
|
||||
),
|
||||
(
|
||||
"ticket.tag.create",
|
||||
"Create classification tags",
|
||||
"Create Workspace classification tags, not workflow authority.",
|
||||
),
|
||||
(
|
||||
"ticket.tag.rename",
|
||||
"Rename classification tags",
|
||||
"Rename Workspace tags with existing replay and compare checks.",
|
||||
),
|
||||
(
|
||||
"ticket.tag.delete",
|
||||
"Delete classification tags",
|
||||
"Delete Workspace tags and their memberships with existing compare checks.",
|
||||
),
|
||||
(
|
||||
"ticket.relation.query",
|
||||
"Search Ticket relations",
|
||||
"Query Workspace Ticket relation metadata.",
|
||||
),
|
||||
(
|
||||
"ticket.orchestration_plan.query",
|
||||
"Read orchestration plans",
|
||||
"Query retained orchestration plan metadata; does not start work.",
|
||||
),
|
||||
(
|
||||
"objective.query",
|
||||
"Search Objectives",
|
||||
"Query the Workspace Objective collection.",
|
||||
),
|
||||
(
|
||||
"objective.create",
|
||||
"Create Objectives",
|
||||
"Create an Objective in this Workspace.",
|
||||
),
|
||||
(
|
||||
"repository.list",
|
||||
"List repositories",
|
||||
"List repository metadata, not checkout or credential access.",
|
||||
),
|
||||
(
|
||||
"workdir.list",
|
||||
"List Workdirs",
|
||||
"List Workdir metadata; does not grant attachment or file access.",
|
||||
),
|
||||
(
|
||||
"workdir.attachment.list",
|
||||
"List attachments",
|
||||
"List Workdir attachment metadata.",
|
||||
),
|
||||
(
|
||||
"worker.list",
|
||||
"List Workers",
|
||||
"List known Workers. Discovery does not grant control.",
|
||||
),
|
||||
(
|
||||
"worker.discover",
|
||||
"Discover Workspace Workers",
|
||||
"Search bounded Worker metadata. Discovery does not grant control or observation.",
|
||||
),
|
||||
(
|
||||
"worker.observe.list",
|
||||
"List observable Workers",
|
||||
"List observation subjects; does not grant access to individual sessions.",
|
||||
),
|
||||
(
|
||||
"worker.create",
|
||||
"Create background Workers",
|
||||
"Use Profile-resolved Worker creation with explicit execution grants. Directional Backend grants separately control the created Worker and its message-only reply.",
|
||||
),
|
||||
(
|
||||
"drive.capabilities",
|
||||
"Read Drive capabilities",
|
||||
"Read provider-supported Drive operations.",
|
||||
),
|
||||
(
|
||||
"drive.root",
|
||||
"Find Drive root",
|
||||
"Read the Workspace Drive root reference.",
|
||||
),
|
||||
(
|
||||
"drive.search",
|
||||
"Search Drive",
|
||||
"Search the Workspace Drive; provider visibility still applies.",
|
||||
),
|
||||
],
|
||||
);
|
||||
add(
|
||||
K::Ticket,
|
||||
&[
|
||||
(
|
||||
"ticket.read",
|
||||
"Read Ticket",
|
||||
"Read the selected Ticket and its thread.",
|
||||
),
|
||||
(
|
||||
"ticket.edit_item",
|
||||
"Edit Ticket",
|
||||
"Edit the selected Ticket content; actor and concurrency checks still apply.",
|
||||
),
|
||||
(
|
||||
"ticket.edit_post",
|
||||
"Edit Ticket post",
|
||||
"Edit an eligible post using its exact observed body.",
|
||||
),
|
||||
(
|
||||
"ticket.event.add",
|
||||
"Add Ticket event",
|
||||
"Append an allowed typed event to the selected Ticket.",
|
||||
),
|
||||
(
|
||||
"ticket.case.update",
|
||||
"Judge Ticket case",
|
||||
"Close, reopen or correct closure with explicit judgment and current compare keys.",
|
||||
),
|
||||
(
|
||||
"ticket.dependency.check",
|
||||
"Check dependencies",
|
||||
"Inspect selected Ticket dependencies; does not start them.",
|
||||
),
|
||||
(
|
||||
"ticket.intake_summary.add",
|
||||
"Add intake summary",
|
||||
"Append an intake summary to the selected Ticket.",
|
||||
),
|
||||
(
|
||||
"ticket.tags.set",
|
||||
"Classify Ticket",
|
||||
"Replace selected Ticket classification tags using the observed full set.",
|
||||
),
|
||||
(
|
||||
"ticket.relation.read",
|
||||
"Read Ticket relations",
|
||||
"Read selected Ticket relation and bounded parent/children link metadata; grants no access to relative Ticket bodies.",
|
||||
),
|
||||
(
|
||||
"ticket.relation.record",
|
||||
"Record Ticket relation",
|
||||
"Record an explicit relation or parent link on the authorized endpoints; reparenting also requires removal authority for the old link.",
|
||||
),
|
||||
(
|
||||
"ticket.relation.remove",
|
||||
"Remove Ticket relation",
|
||||
"Remove an exact relation or observed parent link on the authorized endpoints.",
|
||||
),
|
||||
(
|
||||
"ticket.orchestration_plan.record",
|
||||
"Record orchestration plan",
|
||||
"Record plan metadata for this Ticket; does not grant execution authority.",
|
||||
),
|
||||
(
|
||||
"ticket.mark_ready",
|
||||
"Update intake readiness",
|
||||
"Record an intake readiness change; does not grant execution authority.",
|
||||
),
|
||||
(
|
||||
"ticket.queue",
|
||||
"Request Ticket queueing",
|
||||
"Use the existing authorized Ticket queue route; does not bypass actor restrictions.",
|
||||
),
|
||||
(
|
||||
"ticket.assignment.assign",
|
||||
"Delegate Ticket",
|
||||
"Assign to an authorized Subject; current body, Ticket targets and execution grants are checked separately. Assignment does not start a Worker.",
|
||||
),
|
||||
(
|
||||
"ticket.assignment.clear",
|
||||
"Release Ticket delegation",
|
||||
"Release the observed current Subject assignment; does not stop Workers.",
|
||||
),
|
||||
(
|
||||
"ticket.assignment.update",
|
||||
"Report assignment handling",
|
||||
"Report handling only for the authenticated authorized current delegation with event CAS.",
|
||||
),
|
||||
(
|
||||
"ticket.assignment.answer",
|
||||
"Answer assignment",
|
||||
"Append fenced continuing input to the current assignment; not a new assignment.",
|
||||
),
|
||||
(
|
||||
"ticket.assignment.query",
|
||||
"Read current assignment",
|
||||
"Read selected Ticket assignment tracking.",
|
||||
),
|
||||
(
|
||||
"ticket.assignment.history",
|
||||
"Read assignment history",
|
||||
"Read retained assignment tracking events.",
|
||||
),
|
||||
(
|
||||
"ticket.assignment.diagnostics",
|
||||
"Read assignment diagnostics",
|
||||
"Read retained delivery and start evidence, not execution authority.",
|
||||
),
|
||||
(
|
||||
"ticket.implementation_result.write",
|
||||
"Report implementation result",
|
||||
"Save a bounded result, handoff or blocker; not approval or closure.",
|
||||
),
|
||||
(
|
||||
"ticket.implementation_result.read",
|
||||
"Read implementation result",
|
||||
"Read a saved implementation result; not a new request.",
|
||||
),
|
||||
(
|
||||
"merge_request.open",
|
||||
"Open Merge Request",
|
||||
"Open or reuse a repository-scoped Merge Request for this Ticket.",
|
||||
),
|
||||
],
|
||||
);
|
||||
add(
|
||||
K::Objective,
|
||||
&[
|
||||
(
|
||||
"objective.read",
|
||||
"Read Objective",
|
||||
"Read selected Objective and linked Ticket context.",
|
||||
),
|
||||
(
|
||||
"objective.edit",
|
||||
"Edit Objective",
|
||||
"Edit selected Objective content.",
|
||||
),
|
||||
(
|
||||
"objective.set_state",
|
||||
"Set Objective state",
|
||||
"Change selected Objective state.",
|
||||
),
|
||||
(
|
||||
"objective.link_ticket",
|
||||
"Link Ticket",
|
||||
"Link a Ticket to this Objective; required Ticket read permission is separate.",
|
||||
),
|
||||
(
|
||||
"objective.unlink_ticket",
|
||||
"Unlink Ticket",
|
||||
"Unlink a Ticket from this Objective.",
|
||||
),
|
||||
],
|
||||
);
|
||||
add(
|
||||
K::MergeRequest,
|
||||
&[
|
||||
(
|
||||
"merge_request.read",
|
||||
"Read Merge Request",
|
||||
"Read selected Merge Request and review evidence.",
|
||||
),
|
||||
(
|
||||
"merge_request.readiness",
|
||||
"Check MR readiness",
|
||||
"Check recording-workflow readiness; does not adopt changes.",
|
||||
),
|
||||
(
|
||||
"merge_request.review",
|
||||
"Review Merge Request",
|
||||
"Submit a review only with independently bound review capability and current source.",
|
||||
),
|
||||
(
|
||||
"merge_request.complete",
|
||||
"Record MR integration",
|
||||
"Record an already-applied exact-source integration with existing approval and source checks.",
|
||||
),
|
||||
],
|
||||
);
|
||||
add(
|
||||
K::Repository,
|
||||
&[
|
||||
(
|
||||
"repository.read",
|
||||
"Read repository metadata",
|
||||
"Read selected repository metadata without exposing secrets.",
|
||||
),
|
||||
(
|
||||
"workdir.create",
|
||||
"Create Workdir",
|
||||
"Materialize selected repository using existing provider grants and explicit selectors.",
|
||||
),
|
||||
],
|
||||
);
|
||||
add(
|
||||
K::WorkdirAttachment,
|
||||
&[
|
||||
(
|
||||
"workdir.attachment.read",
|
||||
"Read attachment",
|
||||
"Read metadata for this exact attachment connection.",
|
||||
),
|
||||
(
|
||||
"workdir.detach",
|
||||
"Detach Workdir",
|
||||
"Detach this exact observed connection; stale aliases do not select a replacement.",
|
||||
),
|
||||
],
|
||||
);
|
||||
add(
|
||||
K::WorkspaceConfig,
|
||||
&[
|
||||
(
|
||||
"workspace_config.attach",
|
||||
"Attach configuration",
|
||||
"Attach the selected configuration path and recursive scope.",
|
||||
),
|
||||
(
|
||||
"workspace_config.observe",
|
||||
"Observe configuration",
|
||||
"Observe selected configuration metadata within path scope.",
|
||||
),
|
||||
(
|
||||
"workspace_config.read",
|
||||
"Read configuration",
|
||||
"Read selected configuration within path scope; secret rules still apply.",
|
||||
),
|
||||
(
|
||||
"workspace_config.commit",
|
||||
"Commit configuration",
|
||||
"Commit selected configuration within path scope and provider compare checks.",
|
||||
),
|
||||
],
|
||||
);
|
||||
add(
|
||||
K::DriveEntry,
|
||||
&[
|
||||
(
|
||||
"drive.metadata",
|
||||
"Read Drive metadata",
|
||||
"Read metadata for this exact entry.",
|
||||
),
|
||||
(
|
||||
"drive.list",
|
||||
"List Drive folder",
|
||||
"List children of this exact folder; does not include descendants implicitly.",
|
||||
),
|
||||
(
|
||||
"drive.read_text",
|
||||
"Read Drive text",
|
||||
"Read text of this exact entry.",
|
||||
),
|
||||
(
|
||||
"drive.read_chunk",
|
||||
"Read Drive chunk",
|
||||
"Read a bounded chunk of this exact entry.",
|
||||
),
|
||||
(
|
||||
"drive.create_folder",
|
||||
"Create Drive folder",
|
||||
"Create a folder under this exact parent.",
|
||||
),
|
||||
(
|
||||
"drive.create_text",
|
||||
"Create Drive text",
|
||||
"Create a text entry under this exact parent.",
|
||||
),
|
||||
(
|
||||
"drive.update_text",
|
||||
"Update Drive text",
|
||||
"Update this exact entry with provider concurrency checks.",
|
||||
),
|
||||
(
|
||||
"drive.delete",
|
||||
"Delete Drive entry",
|
||||
"Delete this exact entry if provider conditions permit.",
|
||||
),
|
||||
(
|
||||
"drive.relocate",
|
||||
"Move Drive entry",
|
||||
"Move an entry; source and destination each require explicit permission.",
|
||||
),
|
||||
(
|
||||
"drive.upload",
|
||||
"Upload Drive file",
|
||||
"Upload under this exact parent, subject to provider limits.",
|
||||
),
|
||||
],
|
||||
);
|
||||
add(
|
||||
K::DriveRequest,
|
||||
&[(
|
||||
"drive.request_status",
|
||||
"Read Drive request",
|
||||
"Read the status of this exact provider request; no new execution authority.",
|
||||
)],
|
||||
);
|
||||
add(
|
||||
K::Feature,
|
||||
&[(
|
||||
"mcp",
|
||||
"Enable MCP (temporary coarse permission)",
|
||||
"Retained MCP activation permission before stdio discovery. Not per-server, per-tool or per-call authorization; no equivalent gate is needed by other Features.",
|
||||
)],
|
||||
);
|
||||
add(
|
||||
K::Subject,
|
||||
&[
|
||||
(
|
||||
"session.record",
|
||||
"Record Subject session",
|
||||
"Attribute and record sessions for this Subject; current body ownership is still required.",
|
||||
),
|
||||
(
|
||||
"session.list",
|
||||
"List Subject sessions",
|
||||
"List sessions attributed to this Subject.",
|
||||
),
|
||||
(
|
||||
"session.search",
|
||||
"Search Subject sessions",
|
||||
"Search committed sessions attributed to this Subject.",
|
||||
),
|
||||
(
|
||||
"session.read",
|
||||
"Read Subject session",
|
||||
"Read committed entries from this Subject's sessions.",
|
||||
),
|
||||
(
|
||||
"memory.query",
|
||||
"Search Subject Memory",
|
||||
"Query this Subject's Memory, not Workspace Memory.",
|
||||
),
|
||||
(
|
||||
"memory.read",
|
||||
"Read Subject Memory",
|
||||
"Read this Subject's selected Memory document.",
|
||||
),
|
||||
(
|
||||
"memory.changes.list",
|
||||
"List Memory changes",
|
||||
"Read this Subject's retained Memory changes.",
|
||||
),
|
||||
(
|
||||
"memory.resident.read",
|
||||
"Read resident Memory",
|
||||
"Read this Subject's resident Memory data.",
|
||||
),
|
||||
(
|
||||
"memory.resident.supply",
|
||||
"Supply resident Memory",
|
||||
"Supply protected resident Memory to a model; read permission is separate.",
|
||||
),
|
||||
(
|
||||
"memory.staging.write",
|
||||
"Stage Memory candidate",
|
||||
"Write a candidate to this Subject's staging area.",
|
||||
),
|
||||
(
|
||||
"memory.staging.list",
|
||||
"List Memory candidates",
|
||||
"List this Subject's staging candidates.",
|
||||
),
|
||||
(
|
||||
"memory.staging.read",
|
||||
"Read Memory candidate",
|
||||
"Read this Subject's staging candidate.",
|
||||
),
|
||||
(
|
||||
"memory.candidate.decide",
|
||||
"Decide Memory candidate",
|
||||
"Decide a staged candidate using existing Job/domain constraints.",
|
||||
),
|
||||
(
|
||||
"memory.proposal.validate",
|
||||
"Validate Memory proposal",
|
||||
"Validate a proposal for this Subject.",
|
||||
),
|
||||
(
|
||||
"memory.receipt.read",
|
||||
"Read Memory receipt",
|
||||
"Read retained Memory operation evidence, not new authority.",
|
||||
),
|
||||
(
|
||||
"memory.surface.prepare",
|
||||
"Prepare Memory surface",
|
||||
"Prepare this Subject's surface update under domain checks.",
|
||||
),
|
||||
(
|
||||
"memory.surface.supply",
|
||||
"Supply Memory surface",
|
||||
"Supply protected surface input to a model.",
|
||||
),
|
||||
(
|
||||
"memory.surface.publish",
|
||||
"Publish Memory surface",
|
||||
"Publish this Subject's prepared surface under writer and Job checks.",
|
||||
),
|
||||
(
|
||||
"memory.surface.fail",
|
||||
"Record surface failure",
|
||||
"Record failure of this Subject's prepared surface.",
|
||||
),
|
||||
(
|
||||
"memory.extract.read",
|
||||
"Read extraction input",
|
||||
"Read protected session input for Memory extraction.",
|
||||
),
|
||||
(
|
||||
"memory.extract.supply",
|
||||
"Supply extraction input",
|
||||
"Supply protected extraction input to a model.",
|
||||
),
|
||||
(
|
||||
"memory.extract.commit",
|
||||
"Commit extraction",
|
||||
"Commit extracted candidates under existing source/writer checks.",
|
||||
),
|
||||
(
|
||||
"memory.consolidation.request",
|
||||
"Request consolidation",
|
||||
"Request a bounded consolidation Job; Job grants and origin constraints remain separate.",
|
||||
),
|
||||
(
|
||||
"memory.consolidation.supply",
|
||||
"Supply consolidation input",
|
||||
"Supply frozen consolidation input only within its current Job capability.",
|
||||
),
|
||||
(
|
||||
"journal.startup.read",
|
||||
"Read startup Journal",
|
||||
"Read this Subject's startup Journal context.",
|
||||
),
|
||||
(
|
||||
"journal.startup.supply",
|
||||
"Supply startup Journal",
|
||||
"Supply protected startup Journal context to a model.",
|
||||
),
|
||||
(
|
||||
"journal.compaction.read",
|
||||
"Read compaction Journal",
|
||||
"Read this Subject's compaction Journal context.",
|
||||
),
|
||||
(
|
||||
"journal.compaction.supply",
|
||||
"Supply compaction Journal",
|
||||
"Supply protected compaction context to a model.",
|
||||
),
|
||||
(
|
||||
"journal.recording.request",
|
||||
"Request Journal recording",
|
||||
"Request a bounded recording Job; does not grant arbitrary Job operations.",
|
||||
),
|
||||
(
|
||||
"journal.job.input.read",
|
||||
"Read Journal Job input",
|
||||
"Read frozen input only with the bound current Journal Job capability.",
|
||||
),
|
||||
(
|
||||
"journal.job.input.supply",
|
||||
"Supply Journal Job input",
|
||||
"Supply frozen input only with current Job, origin and policy authority.",
|
||||
),
|
||||
(
|
||||
"journal.state.read",
|
||||
"Read Journal state",
|
||||
"Read this Subject's Journal state.",
|
||||
),
|
||||
(
|
||||
"journal.text.read",
|
||||
"Read Journal text",
|
||||
"Read this Subject's Journal text.",
|
||||
),
|
||||
(
|
||||
"journal.operation.query",
|
||||
"Query Journal operations",
|
||||
"Query retained Journal operations.",
|
||||
),
|
||||
(
|
||||
"journal.operation.list",
|
||||
"List Journal operations",
|
||||
"List retained Journal operations.",
|
||||
),
|
||||
(
|
||||
"journal.operation.coverage.read",
|
||||
"Read Journal coverage",
|
||||
"Read operation coverage evidence.",
|
||||
),
|
||||
(
|
||||
"journal.entry.create",
|
||||
"Create Journal entry",
|
||||
"Create an entry within the bound Journal Job.",
|
||||
),
|
||||
(
|
||||
"journal.entry.fix",
|
||||
"Fix Journal entry",
|
||||
"Fix an entry within the bound Journal Job.",
|
||||
),
|
||||
(
|
||||
"journal.entry.rewrite",
|
||||
"Rewrite Journal entry",
|
||||
"Rewrite an entry within the bound Journal Job.",
|
||||
),
|
||||
(
|
||||
"journal.position.update",
|
||||
"Update Journal position",
|
||||
"Update position within the current Journal writer/attempt.",
|
||||
),
|
||||
(
|
||||
"journal.unchanged.confirm",
|
||||
"Confirm unchanged Journal",
|
||||
"Confirm no change within the current Journal writer/attempt.",
|
||||
),
|
||||
(
|
||||
"journal.checkpoint",
|
||||
"Checkpoint Journal",
|
||||
"Checkpoint the current Journal recording Job.",
|
||||
),
|
||||
(
|
||||
"journal.finish",
|
||||
"Finish Journal recording",
|
||||
"Finish the bound Journal Job; not a general work-completion judgment.",
|
||||
),
|
||||
],
|
||||
);
|
||||
add(
|
||||
K::Workspace,
|
||||
&[
|
||||
(
|
||||
"memory.document.read",
|
||||
"Read Workspace Memory document",
|
||||
"Read a legacy Workspace Memory document; not Subject Memory.",
|
||||
),
|
||||
(
|
||||
"memory.document.update",
|
||||
"Update Workspace Memory document",
|
||||
"Update a legacy Workspace Memory document with existing storage constraints.",
|
||||
),
|
||||
(
|
||||
"memory.audit.append",
|
||||
"Append Workspace Memory audit",
|
||||
"Append a Workspace Memory audit event.",
|
||||
),
|
||||
(
|
||||
"memory.staging.close",
|
||||
"Close Workspace Memory candidate",
|
||||
"Close a legacy Workspace staging candidate.",
|
||||
),
|
||||
],
|
||||
);
|
||||
add(
|
||||
K::Subject,
|
||||
&[
|
||||
(
|
||||
"chat.read",
|
||||
"Read Subject conversations",
|
||||
"Read conversations belonging to the current Subject body; membership and current ownership remain required.",
|
||||
),
|
||||
(
|
||||
"chat.send",
|
||||
"Send Subject message",
|
||||
"Explicitly publish to a permitted conversation as the current Subject body. Background Worker messaging does not grant publication.",
|
||||
),
|
||||
],
|
||||
);
|
||||
// The same exact operation may target either independent Memory domain. This
|
||||
// adds accepted kinds, never cross-kind containment or a second descriptor.
|
||||
for descriptor in &mut catalog {
|
||||
if matches!(
|
||||
descriptor.operation.as_str(),
|
||||
"memory.query"
|
||||
| "memory.resident.read"
|
||||
| "memory.resident.supply"
|
||||
| "memory.staging.write"
|
||||
| "memory.staging.list"
|
||||
| "memory.staging.read"
|
||||
| "memory.extract.read"
|
||||
| "memory.extract.supply"
|
||||
| "memory.extract.commit"
|
||||
| "memory.consolidation.request"
|
||||
) {
|
||||
descriptor.resource_kinds.push(K::Workspace);
|
||||
descriptor.description.push_str(" The Workspace kind selects the separate legacy Workspace Memory domain, never all Subjects.");
|
||||
}
|
||||
}
|
||||
catalog.push(PermissionOperationDescriptor {
|
||||
operation: "notification.diagnostics.read".into(), label: "Read notification diagnostics".into(),
|
||||
description: "Read bounded saved delivery evidence for the exact source resource; Runtime acceptance does not prove processing.".into(),
|
||||
resource_kinds: vec![K::Workspace, K::Ticket, K::MergeRequest, K::Job],
|
||||
availability: PermissionOperationAvailability::Available,
|
||||
});
|
||||
// Operation use is Workspace-bound but never grants a target. Legacy Worker
|
||||
// scopes stay exact; management does not convert or discard their limits.
|
||||
for (operation, label) in [
|
||||
("worker.messaging", "Message Workers"),
|
||||
("worker.send_input", "Submit to Worker"),
|
||||
("worker.cancel", "Cancel Worker turn"),
|
||||
("worker.stop", "Stop Worker"),
|
||||
("worker.restore", "Restore Worker"),
|
||||
("worker.remove", "Remove Worker"),
|
||||
("worker.observe", "Observe Worker session"),
|
||||
] {
|
||||
catalog.push(PermissionOperationDescriptor {
|
||||
operation: operation.into(), label: label.into(),
|
||||
description: "Operation use permission and directional Backend target control grants are separate. Acceptance does not prove processing or success; lifecycle state conditions still apply.".into(),
|
||||
resource_kinds: vec![K::WorkerOperation],
|
||||
availability: PermissionOperationAvailability::Available,
|
||||
});
|
||||
}
|
||||
for (operation, label) in [
|
||||
("subworker.spawn", "Create Internal SubWorker"),
|
||||
("subworker.send_input", "Submit to Internal SubWorker"),
|
||||
("subworker.stop", "Stop Internal SubWorker"),
|
||||
("subworker.observe", "Observe Internal SubWorker"),
|
||||
] {
|
||||
catalog.push(PermissionOperationDescriptor {
|
||||
operation: operation.into(), label: label.into(),
|
||||
description: "Parent-owned Internal SubWorkers are distinct from independently controlled Runtime Workers. Existing exact parent/name scopes are retained.".into(),
|
||||
resource_kinds: vec![],
|
||||
availability: PermissionOperationAvailability::Unavailable { reason: "A safe parent-bound management selector is not yet available. Existing scopes may be retained or removed, not widened by replacing parent identifiers.".into() },
|
||||
});
|
||||
}
|
||||
catalog
|
||||
}
|
||||
|
||||
/// Validate additions only, after receipt lookup and current-policy CAS in the
|
||||
/// management store transaction. Unsupported entries may be kept exactly in their
|
||||
/// original lane or removed, never copied to another lane or broadened.
|
||||
pub fn validate_managed_subject_permissions(
|
||||
current: Option<&SubjectPermissions>,
|
||||
update: &SubjectPermissionsUpdate,
|
||||
) -> Result<(), String> {
|
||||
let catalog = subject_permission_operations();
|
||||
for (next, previous) in [
|
||||
(
|
||||
&update.execution,
|
||||
current.map(|p| p.execution.as_slice()).unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
&update.delegation,
|
||||
current.map(|p| p.delegation.as_slice()).unwrap_or_default(),
|
||||
),
|
||||
] {
|
||||
for permission in next {
|
||||
if previous.contains(permission) {
|
||||
continue;
|
||||
}
|
||||
permission.validate()?;
|
||||
let descriptor = catalog
|
||||
.iter()
|
||||
.find(|d| d.operation == permission.operation)
|
||||
.ok_or_else(|| format!("unknown management operation: {}", permission.operation))?;
|
||||
if !matches!(
|
||||
descriptor.availability,
|
||||
PermissionOperationAvailability::Available
|
||||
) {
|
||||
return Err(format!(
|
||||
"operation is unavailable for management: {}",
|
||||
permission.operation
|
||||
));
|
||||
}
|
||||
if !descriptor
|
||||
.resource_kinds
|
||||
.contains(&permission.resource.kind())
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid resource kind for operation: {}",
|
||||
permission.operation
|
||||
));
|
||||
}
|
||||
if permission.operation == "bash.execute"
|
||||
&& !matches!(&permission.resource,
|
||||
PermissionResource::Workdir { path, recursive: true, .. } if path == ".")
|
||||
{
|
||||
return Err(
|
||||
"bash.execute requires the whole Workdir (path '.' and recursive true)".into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user