Compare commits
394
Commits
88811466f2
...
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 | ||
|
|
d115819f51 | ||
|
|
822a084b31 | ||
|
|
c2df3041f9 | ||
|
|
aabc502f30 | ||
|
|
65ead5fffd | ||
|
|
63d581914c | ||
|
|
da378fc902 | ||
|
|
8611078058 | ||
|
|
26ab6130e0 | ||
|
|
a82e70047a | ||
|
|
12d1588c05 | ||
|
|
523bade3a6 | ||
|
|
e436b2ae58 | ||
|
|
05ba6a43ad | ||
|
|
4d98119440 | ||
|
|
c6d73e8286 | ||
|
|
5873f25073 | ||
|
|
ebce7ab7b1 | ||
|
|
1a065db0fc | ||
|
|
6d8c49b72d | ||
|
|
925bce2a40 | ||
|
|
b63bdb4402 | ||
|
|
dbcea6c34b | ||
|
|
27b6c688e4 | ||
|
|
a2487b575d | ||
|
|
b10b731531 | ||
|
|
5c5727e3f8 | ||
|
|
c0223a0dea | ||
|
|
d3fcfb2977 | ||
|
|
66bb9d0b82 | ||
|
|
49bfbfb235 | ||
|
|
f597f1b57a | ||
|
|
df9d056d42 | ||
|
|
f3d4aba54e | ||
|
|
9b0d42fdbc | ||
|
|
61533fadb6 | ||
|
|
b1076f1d65 | ||
|
|
56795fb92c | ||
|
|
26434bb867 | ||
|
|
a6a97bca74 | ||
|
|
27bf74a051 | ||
|
|
96e5ceb4fb | ||
|
|
cc9177728d | ||
|
|
ae26cdfbef | ||
|
|
45d8f4d69f | ||
|
|
21df210a7f | ||
|
|
2a55bef036 | ||
|
|
e19742d509 | ||
|
|
2b1f68a3ea | ||
|
|
74d0af93e3 | ||
|
|
520a898bd8 | ||
|
|
9e1de93a04 | ||
|
|
98023b9872 | ||
|
|
7f03db9d79 | ||
|
|
4dd5207d1e | ||
|
|
a558c7656a | ||
|
|
b1fe562e15 | ||
|
|
ba3ebaa68c | ||
|
|
71ab4c06b9 | ||
|
|
e9397fa0dd | ||
|
|
298f8c1330 |
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" }
|
||||
|
||||
@@ -37,6 +37,7 @@ chrono = { version = "0.4", default-features = false, features = ["serde", "cloc
|
||||
agen-macros = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["test-util"] }
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
tempfile = { workspace = true }
|
||||
dotenv = "0.15"
|
||||
|
||||
+239
-170
@@ -88,6 +88,63 @@ pub enum EngineError {
|
||||
ToolAttemptFence(String),
|
||||
}
|
||||
|
||||
/// Policy completion is not permission to start work when an interruption was
|
||||
/// queued during its final poll. Cancel wins when both requests are observed.
|
||||
fn check_policy_interruption(
|
||||
cancel_rx: &mut mpsc::Receiver<()>,
|
||||
pause_rx: &mut mpsc::Receiver<()>,
|
||||
) -> Result<(), EngineError> {
|
||||
if cancel_rx.try_recv().is_ok() {
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
if pause_rx.try_recv().is_ok() {
|
||||
return Err(EngineError::PauseRequested);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll one policy invocation, never a second copy of its side effects. Dropping
|
||||
/// a pending policy stops admission, not any already-owned tool execution; the
|
||||
/// caller must drain that execution through its original attempt fence.
|
||||
async fn await_tool_policy<T>(
|
||||
policy: impl Future<Output = Result<T, InterceptorError>>,
|
||||
phase: InterceptorPhase,
|
||||
cancel_rx: &mut mpsc::Receiver<()>,
|
||||
pause_rx: &mut mpsc::Receiver<()>,
|
||||
) -> Result<T, EngineError> {
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
Some(()) = cancel_rx.recv() => return Err(EngineError::Cancelled),
|
||||
Some(()) = pause_rx.recv() => return Err(EngineError::PauseRequested),
|
||||
result = policy => result,
|
||||
};
|
||||
check_policy_interruption(cancel_rx, pause_rx)?;
|
||||
result.map_err(|error| InterceptorFailure::new(phase, error).into())
|
||||
}
|
||||
|
||||
/// A consumed Pause still bounds post-policy processing while owned tools drain.
|
||||
/// Terminal history and publication happen before this wait, so expiring it
|
||||
/// cannot discard the result or reopen the execution attempt.
|
||||
#[derive(Clone, Copy)]
|
||||
enum PostToolPolicyWait {
|
||||
Skip,
|
||||
Interruptible,
|
||||
UntilPauseSafeBoundary(TokioInstant),
|
||||
}
|
||||
|
||||
impl PostToolPolicyWait {
|
||||
fn for_pause_deadline(deadline: Option<TokioInstant>) -> Self {
|
||||
deadline.map_or(Self::Interruptible, Self::UntilPauseSafeBoundary)
|
||||
}
|
||||
|
||||
fn pause_deadline(self) -> Option<TokioInstant> {
|
||||
match self {
|
||||
Self::UntilPauseSafeBoundary(deadline) => Some(deadline),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool registration error
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ToolRegistryError {
|
||||
@@ -770,14 +827,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
}
|
||||
}
|
||||
|
||||
fn try_paused(&mut self) -> bool {
|
||||
use tokio::sync::mpsc::error::TryRecvError;
|
||||
match self.pause_rx.try_recv() {
|
||||
Ok(()) => true,
|
||||
Err(TryRecvError::Empty | TryRecvError::Disconnected) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a text block observer with scoped callbacks.
|
||||
///
|
||||
/// The setup closure is called once per text block. Inside it, register
|
||||
@@ -975,10 +1024,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
|
||||
/// Register a callback invoked once per completed tool execution.
|
||||
///
|
||||
/// Fired after `post_tool_call` interceptors and any `content`
|
||||
/// truncation from `tool_output_limits`, so the callback observes
|
||||
/// exactly what is persisted to history. Intended for callers that need
|
||||
/// to forward tool results to clients.
|
||||
/// Fired after `content` truncation from `tool_output_limits` and terminal
|
||||
/// history commit, before `post_tool_call` policy. The callback observes
|
||||
/// exactly what is persisted even if that policy is interrupted. Intended
|
||||
/// for callers that need to forward tool results to clients.
|
||||
pub fn on_tool_result(&mut self, callback: impl Fn(&ToolResult) + Send + Sync + 'static) {
|
||||
self.tool_result_cbs.push(Box::new(callback));
|
||||
}
|
||||
@@ -1431,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,
|
||||
@@ -1447,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());
|
||||
@@ -1552,43 +1592,24 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
tool,
|
||||
context,
|
||||
};
|
||||
let action = tokio::select! {
|
||||
result = self.interceptor.pre_tool_call(&mut info) => {
|
||||
result.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
let action = await_tool_policy(
|
||||
self.interceptor.pre_tool_call(&mut info),
|
||||
InterceptorPhase::PreToolCall,
|
||||
error,
|
||||
))
|
||||
})?
|
||||
}
|
||||
pause = self.pause_rx.recv() => {
|
||||
if pause.is_some() {
|
||||
info!(call_id = %expected_tool_use_id, "Paused while awaiting pre-tool policy");
|
||||
}
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
None,
|
||||
)?;
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
)
|
||||
.await;
|
||||
let action = match action {
|
||||
Ok(action) => action,
|
||||
Err(error @ (EngineError::PauseRequested | EngineError::Cancelled)) => {
|
||||
self.append_early_tool_call(history, annotate, &tool_call, call_index, None)?;
|
||||
batch.dispatched_any = true;
|
||||
return Ok(EarlyToolAdmission::Pause);
|
||||
}
|
||||
cancel = self.cancel_rx.recv() => {
|
||||
if cancel.is_some() {
|
||||
info!(call_id = %expected_tool_use_id, "Cancelled while awaiting pre-tool policy");
|
||||
}
|
||||
self.append_early_tool_call(
|
||||
history,
|
||||
annotate,
|
||||
&tool_call,
|
||||
call_index,
|
||||
None,
|
||||
)?;
|
||||
batch.dispatched_any = true;
|
||||
return Err(EngineError::Cancelled);
|
||||
return match error {
|
||||
EngineError::PauseRequested => Ok(EarlyToolAdmission::Pause),
|
||||
_ => Err(error),
|
||||
};
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if info.call.id != expected_tool_use_id {
|
||||
return Err(InterceptorFailure::new(
|
||||
@@ -1609,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()),
|
||||
@@ -1617,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());
|
||||
@@ -1665,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",
|
||||
@@ -1700,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}"),
|
||||
@@ -1715,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);
|
||||
}
|
||||
@@ -1737,7 +1743,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::Interruptible,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1764,7 +1770,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1812,7 +1818,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
batch.turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1835,7 +1841,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
ToolResult::outcome_unknown(call_id),
|
||||
Some(attempt_id),
|
||||
batch.turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1858,28 +1864,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
force_cancel: bool,
|
||||
initial_pause_requested: bool,
|
||||
) -> Result<ToolExecutionResult, EngineError> {
|
||||
while let Some(result) = batch.synthetic_results.pop_front() {
|
||||
if let Err(error) = self
|
||||
.finalize_and_commit_tool_result(
|
||||
history,
|
||||
annotate,
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
true,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let cleanup_error = self
|
||||
.cancel_and_terminalize_early_batch(history, annotate, &mut batch)
|
||||
.await;
|
||||
return Err(cleanup_error.unwrap_or(error));
|
||||
}
|
||||
}
|
||||
|
||||
if force_cancel {
|
||||
if let Some(error) = self
|
||||
.cancel_and_terminalize_early_batch(history, annotate, &mut batch)
|
||||
@@ -1893,6 +1877,28 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
let mut pause_requested = initial_pause_requested;
|
||||
let mut pause_deadline = initial_pause_requested
|
||||
.then(|| TokioInstant::now() + self.tool_execution_policy.pause_safe_boundary_timeout);
|
||||
while let Some(result) = batch.synthetic_results.pop_front() {
|
||||
if let Err(error) = self
|
||||
.finalize_and_commit_tool_result(
|
||||
history,
|
||||
annotate,
|
||||
result,
|
||||
None,
|
||||
batch.turn_id,
|
||||
PostToolPolicyWait::for_pause_deadline(pause_deadline),
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let cleanup_error = self
|
||||
.cancel_and_terminalize_early_batch(history, annotate, &mut batch)
|
||||
.await;
|
||||
return Err(cleanup_error.unwrap_or(error));
|
||||
}
|
||||
}
|
||||
|
||||
while !batch.futures.is_empty() {
|
||||
tokio::select! {
|
||||
biased;
|
||||
@@ -1904,7 +1910,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
batch.turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::for_pause_deadline(pause_deadline),
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -1966,8 +1972,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
|
||||
// A pause observed before provider ownership starts leaves every call
|
||||
// NotStarted and therefore eligible for an explicit later retry.
|
||||
if self.try_paused() {
|
||||
return Ok(ToolExecutionResult::Paused);
|
||||
match check_policy_interruption(&mut self.cancel_rx, &mut self.pause_rx) {
|
||||
Ok(()) => {}
|
||||
Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
// Map from tool call ID to (ToolCall, Meta, Tool, Context)
|
||||
@@ -1978,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(
|
||||
@@ -2006,16 +2028,18 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
context,
|
||||
};
|
||||
|
||||
let pre_tool_action =
|
||||
self.interceptor
|
||||
.pre_tool_call(&mut info)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
let pre_tool_action = match await_tool_policy(
|
||||
self.interceptor.pre_tool_call(&mut info),
|
||||
InterceptorPhase::PreToolCall,
|
||||
error,
|
||||
))
|
||||
})?;
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(action) => action,
|
||||
Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused),
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
if info.call.id != expected_tool_use_id {
|
||||
return Err(InterceptorFailure::new(
|
||||
InterceptorPhase::PreToolCall,
|
||||
@@ -2045,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),
|
||||
@@ -2064,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(),
|
||||
@@ -2077,11 +2103,20 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
// Recheck after all policies and immediately before provider ownership.
|
||||
// No tool task exists yet: these calls remain proven NotStarted.
|
||||
match check_policy_interruption(&mut self.cancel_rx, &mut self.pause_rx) {
|
||||
Ok(()) => {}
|
||||
Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused),
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
// Phase 2: Execute approved tools in parallel. FuturesUnordered yields
|
||||
// each terminal result as soon as that call completes instead of
|
||||
// holding fast siblings behind the slowest call in the batch.
|
||||
@@ -2158,7 +2193,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
None,
|
||||
turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::Interruptible,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2190,7 +2225,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::for_pause_deadline(pause_deadline),
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2271,7 +2306,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2299,7 +2334,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
ToolResult::outcome_unknown(call_id),
|
||||
Some(attempt_id),
|
||||
turn_id,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2340,8 +2375,8 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply post-execution policy, bound the model-visible payload, durably
|
||||
/// append one terminal ToolResult, and only then publish it to observers.
|
||||
/// Bound the model-visible payload, durably append one terminal ToolResult,
|
||||
/// publish it, then await interruptible post-execution policy.
|
||||
async fn finalize_and_commit_tool_result(
|
||||
&mut self,
|
||||
history: &mut History<A>,
|
||||
@@ -2349,7 +2384,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
mut tool_result: ToolResult,
|
||||
execution_attempt_id: Option<&str>,
|
||||
turn_id: usize,
|
||||
invoke_post_hook: bool,
|
||||
post_policy_wait: PostToolPolicyWait,
|
||||
call_info_map: &HashMap<
|
||||
String,
|
||||
(
|
||||
@@ -2434,7 +2469,9 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
);
|
||||
self.emit_tool_result(&tool_result);
|
||||
|
||||
if invoke_post_hook && let Some((tool_call, meta, tool, context)) = call_info {
|
||||
if !matches!(post_policy_wait, PostToolPolicyWait::Skip)
|
||||
&& let Some((tool_call, meta, tool, context)) = call_info
|
||||
{
|
||||
let invocation = self.interceptor_invocation(
|
||||
InterceptorPhase::PostToolCall,
|
||||
Some(turn_id),
|
||||
@@ -2450,27 +2487,20 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
tool: tool.clone(),
|
||||
context: context.clone(),
|
||||
};
|
||||
let pause_deadline = post_policy_wait.pause_deadline();
|
||||
let post_tool_action = tokio::select! {
|
||||
result = self.interceptor.post_tool_call(&info) => {
|
||||
result.map_err(|error| {
|
||||
EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::PostToolCall,
|
||||
error,
|
||||
))
|
||||
})?
|
||||
}
|
||||
pause = self.pause_rx.recv() => {
|
||||
if pause.is_some() {
|
||||
info!(call_id = %tool_call.id, "Paused while awaiting post-tool policy");
|
||||
}
|
||||
biased;
|
||||
// Poll the existing safe-boundary deadline even when the Pause
|
||||
// that established it was consumed by an outer policy gate.
|
||||
_ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => {
|
||||
return Err(EngineError::PauseRequested);
|
||||
}
|
||||
cancel = self.cancel_rx.recv() => {
|
||||
if cancel.is_some() {
|
||||
info!(call_id = %tool_call.id, "Cancelled while awaiting post-tool policy");
|
||||
}
|
||||
return Err(EngineError::Cancelled);
|
||||
}
|
||||
result = await_tool_policy(
|
||||
self.interceptor.post_tool_call(&info),
|
||||
InterceptorPhase::PostToolCall,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
) => result?,
|
||||
};
|
||||
if let PostToolAction::Abort(reason) = post_tool_action {
|
||||
return Err(EngineError::Aborted(reason));
|
||||
@@ -2508,7 +2538,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
ToolResult::outcome_unknown(&call_id),
|
||||
None,
|
||||
self.turn_count,
|
||||
false,
|
||||
PostToolPolicyWait::Skip,
|
||||
&call_info_map,
|
||||
&mut attempt_fence,
|
||||
&mut terminal_call_ids,
|
||||
@@ -2970,22 +3000,29 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
)),
|
||||
0,
|
||||
);
|
||||
let assistant_turn_action = self
|
||||
.interceptor
|
||||
let assistant_turn_action = await_tool_policy(
|
||||
self.interceptor
|
||||
.on_assistant_turn_end(AssistantTurnEndContext {
|
||||
invocation: assistant_invocation,
|
||||
assistant_entries: &history.entries()[assistant_start..],
|
||||
history: history.entries(),
|
||||
tool_calls: &tool_calls,
|
||||
})
|
||||
}),
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
&mut self.cancel_rx,
|
||||
&mut self.pause_rx,
|
||||
)
|
||||
.await;
|
||||
let assistant_turn_action = match assistant_turn_action {
|
||||
Ok(action) => action,
|
||||
Err(EngineError::PauseRequested) => {
|
||||
if let Some(batch) = early_tools.take() {
|
||||
self.drain_early_tool_batch(history, annotate, batch, false, true)
|
||||
.await?;
|
||||
}
|
||||
return Ok(EngineResult::Paused);
|
||||
}
|
||||
Err(error) => {
|
||||
let error = EngineError::from(InterceptorFailure::new(
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
error,
|
||||
));
|
||||
if let Some(batch) = early_tools.take()
|
||||
&& let Err(cleanup_error) = self
|
||||
.drain_early_tool_batch(history, annotate, batch, true, false)
|
||||
@@ -3551,7 +3588,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> {
|
||||
result,
|
||||
Some(&attempt_id),
|
||||
batch.turn_id,
|
||||
true,
|
||||
PostToolPolicyWait::Interruptible,
|
||||
&batch.call_info_map,
|
||||
&mut batch.attempt_fence,
|
||||
&mut batch.terminal_call_ids,
|
||||
@@ -4289,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::*;
|
||||
|
||||
@@ -4,6 +4,13 @@
|
||||
//! keeping host-specific policy out of the Engine. Additional model input must go
|
||||
//! through history appends: inserting new facts only into an in-flight request
|
||||
//! would make its input impossible to reconstruct from stored history.
|
||||
//!
|
||||
//! Tool pre/post policy and assistant-turn-end waits are interruptible by Cancel or Pause;
|
||||
//! Cancel wins when both are observed. Pending policy futures may be dropped, not replayed, so
|
||||
//! hosts must not depend on their completion for cleanup of already-owned effects. The Engine
|
||||
//! drains owned tool attempts through their original fences. Terminal tool history and result
|
||||
//! publication precede post-tool policy; interruption cannot erase that result. Pause draining
|
||||
//! bounds post-tool waits by the existing safe-boundary deadline.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -302,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,
|
||||
}
|
||||
|
||||
@@ -317,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,
|
||||
}
|
||||
|
||||
@@ -337,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,9 +225,9 @@ impl GeminiScheme {
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// Text block stop.
|
||||
if let Some(index) = state.last_text_index.take() {
|
||||
events.push(Event::BlockStop(BlockStop {
|
||||
index: candidate_index,
|
||||
index,
|
||||
block_type: BlockType::Text,
|
||||
stop_reason,
|
||||
reasoning: None,
|
||||
@@ -210,6 +235,7 @@ impl GeminiScheme {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if events.is_empty() {
|
||||
Ok(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 {
|
||||
|
||||
@@ -106,15 +106,23 @@ pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
/// Reload the existing file, update `tokens.{id_token,access_token,refresh_token}`
|
||||
/// and `last_refresh`, and write it back. Equivalent to Codex CLI's `persist_tokens`.
|
||||
///
|
||||
/// Reload and merge before writing to preserve fields if a concurrent
|
||||
/// Codex CLI / another process has already refreshed.
|
||||
/// Reload and merge before writing to preserve fields updated by a concurrent
|
||||
/// Codex CLI / another process. Account and refresh token must still match the
|
||||
/// request's input: merge unrelated fields, but return the current snapshot
|
||||
/// without writing if another login or token rotation is observed.
|
||||
pub async fn persist_refreshed(
|
||||
path: &Path,
|
||||
refresh_input: &AuthSnapshot,
|
||||
new_id_token: Option<String>,
|
||||
new_access_token: Option<String>,
|
||||
new_refresh_token: Option<String>,
|
||||
) -> Result<AuthSnapshot, CodexAuthError> {
|
||||
let mut current = load(path).await?;
|
||||
if current.account_id != refresh_input.account_id
|
||||
|| current.refresh_token != refresh_input.refresh_token
|
||||
{
|
||||
return Ok(current);
|
||||
}
|
||||
let raw = &mut current.raw;
|
||||
let tokens = raw
|
||||
.get_mut("tokens")
|
||||
@@ -243,8 +251,14 @@ mod tests {
|
||||
"agent_identity":{"workspace_id":"w","agent_runtime_id":"r","agent_private_key":"k","registered_at":"x"}
|
||||
}"#,
|
||||
);
|
||||
let updated =
|
||||
persist_refreshed(&path, None, Some("new-acc".into()), Some("new-ref".into()))
|
||||
let input = load(&path).await.unwrap();
|
||||
let updated = persist_refreshed(
|
||||
&path,
|
||||
&input,
|
||||
None,
|
||||
Some("new-acc".into()),
|
||||
Some("new-ref".into()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(updated.access_token, "new-acc");
|
||||
@@ -266,7 +280,8 @@ mod tests {
|
||||
);
|
||||
// Change the existing file to 644, then persist → check that it returns to 600
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
persist_refreshed(&path, None, Some("a2".into()), None)
|
||||
let input = load(&path).await.unwrap();
|
||||
persist_refreshed(&path, &input, None, Some("a2".into()), None)
|
||||
.await
|
||||
.unwrap();
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
|
||||
@@ -17,6 +17,8 @@ mod auth_json;
|
||||
mod error;
|
||||
mod jwt;
|
||||
mod refresh;
|
||||
#[cfg(test)]
|
||||
mod refresh_tests;
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
@@ -40,6 +42,9 @@ pub struct CodexAuthProvider {
|
||||
auth_path: PathBuf,
|
||||
refresh_endpoint: String,
|
||||
http: reqwest::Client,
|
||||
refresh_limits: refresh::RefreshLimits,
|
||||
#[cfg(test)]
|
||||
before_guarded_reload: Option<Arc<tokio::sync::Barrier>>,
|
||||
state: Arc<Mutex<State>>,
|
||||
}
|
||||
|
||||
@@ -76,6 +81,9 @@ impl CodexAuthProvider {
|
||||
auth_path: codex_home.join("auth.json"),
|
||||
refresh_endpoint: refresh::REFRESH_URL.to_string(),
|
||||
http: reqwest::Client::new(),
|
||||
refresh_limits: refresh::RefreshLimits::default(),
|
||||
#[cfg(test)]
|
||||
before_guarded_reload: None,
|
||||
state: Arc::new(Mutex::new(State::default())),
|
||||
}
|
||||
}
|
||||
@@ -106,8 +114,13 @@ impl CodexAuthProvider {
|
||||
return Ok(snap);
|
||||
}
|
||||
|
||||
// 3. Reload immediately before refreshing and check that account_id matches (guarded reload).
|
||||
// If it differs, another process has already updated it → skip refreshing
|
||||
// 3. Reload immediately before refreshing (guarded reload).
|
||||
// Skip refresh if another process supplied fresh tokens or changed account_id.
|
||||
#[cfg(test)]
|
||||
if let Some(barrier) = &self.before_guarded_reload {
|
||||
barrier.wait().await;
|
||||
barrier.wait().await;
|
||||
}
|
||||
let pre_refresh = auth_json::load(&self.auth_path).await?;
|
||||
if !is_stale(&pre_refresh) {
|
||||
state.cached = Some(pre_refresh.clone());
|
||||
@@ -124,12 +137,14 @@ impl CodexAuthProvider {
|
||||
&self.http,
|
||||
&self.refresh_endpoint,
|
||||
&pre_refresh.refresh_token,
|
||||
&self.refresh_limits,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 5. Write back (persist_refreshed reloads and merges before writing)
|
||||
let new_snap = auth_json::persist_refreshed(
|
||||
&self.auth_path,
|
||||
&pre_refresh,
|
||||
refreshed.id_token,
|
||||
refreshed.access_token,
|
||||
refreshed.refresh_token,
|
||||
@@ -210,13 +225,18 @@ mod tests {
|
||||
use wiremock::matchers::{method, path as url_path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn make_jwt(payload: serde_json::Value) -> String {
|
||||
pub(super) fn make_jwt(payload: serde_json::Value) -> String {
|
||||
let p = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(serde_json::to_vec(&payload).unwrap());
|
||||
format!("h.{p}.s")
|
||||
}
|
||||
|
||||
fn write_auth(dir: &std::path::Path, exp: i64, fedramp: bool, refresh: &str) -> PathBuf {
|
||||
pub(super) fn write_auth(
|
||||
dir: &std::path::Path,
|
||||
exp: i64,
|
||||
fedramp: bool,
|
||||
refresh: &str,
|
||||
) -> PathBuf {
|
||||
let id_token = make_jwt(json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_account_id": "acc-xyz",
|
||||
|
||||
@@ -10,7 +10,26 @@ use super::error::{CodexAuthError, PermanentReason};
|
||||
|
||||
pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann";
|
||||
pub const REFRESH_URL: &str = "https://auth.openai.com/oauth/token";
|
||||
pub const DEFAULT_REFRESH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Limits cover send, both response bodies, and decode, not auth file I/O.
|
||||
/// OAuth responses contain only a few tokens; 64 KiB leaves ample JWT headroom.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct RefreshLimits {
|
||||
pub timeout: Duration,
|
||||
pub max_body_bytes: usize,
|
||||
#[cfg(test)]
|
||||
pub body_started: Option<std::sync::Arc<tokio::sync::Notify>>,
|
||||
}
|
||||
|
||||
impl Default for RefreshLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timeout: Duration::from_secs(30),
|
||||
max_body_bytes: 64 * 1024,
|
||||
#[cfg(test)]
|
||||
body_started: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RefreshRequest<'a> {
|
||||
@@ -37,54 +56,94 @@ pub async fn request_refresh(
|
||||
client: &reqwest::Client,
|
||||
endpoint: &str,
|
||||
refresh_token: &str,
|
||||
limits: &RefreshLimits,
|
||||
) -> Result<RefreshResponse, CodexAuthError> {
|
||||
let deadline = tokio::time::Instant::now() + limits.timeout;
|
||||
let result = tokio::time::timeout_at(
|
||||
deadline,
|
||||
request_refresh_bounded(client, endpoint, refresh_token, limits),
|
||||
)
|
||||
.await;
|
||||
// JSON decode is synchronous. The byte bound limits its work, and this
|
||||
// post-check prevents a decode that crosses the deadline from being persisted.
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Err(CodexAuthError::RefreshTransient(
|
||||
"oauth_token_refresh timed out".into(),
|
||||
));
|
||||
}
|
||||
result.map_err(|_| CodexAuthError::RefreshTransient("oauth_token_refresh timed out".into()))?
|
||||
}
|
||||
|
||||
async fn request_refresh_bounded(
|
||||
client: &reqwest::Client,
|
||||
endpoint: &str,
|
||||
refresh_token: &str,
|
||||
limits: &RefreshLimits,
|
||||
) -> Result<RefreshResponse, CodexAuthError> {
|
||||
let body = RefreshRequest {
|
||||
client_id: CLIENT_ID,
|
||||
grant_type: "refresh_token",
|
||||
refresh_token,
|
||||
};
|
||||
let response = response_with_timeout(
|
||||
client
|
||||
let mut response = client
|
||||
.post(endpoint)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send(),
|
||||
DEFAULT_REFRESH_TIMEOUT,
|
||||
)
|
||||
.await?;
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| CodexAuthError::RefreshTransient("send failed".into()))?;
|
||||
|
||||
let status = response.status();
|
||||
if status.is_success() {
|
||||
response
|
||||
.json::<RefreshResponse>()
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|len| len > limits.max_body_bytes as u64)
|
||||
{
|
||||
return Err(body_too_large());
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Some(started) = &limits.body_started {
|
||||
started.notify_one();
|
||||
}
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(chunk) = response
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| CodexAuthError::RefreshTransient(format!("parse response: {e}")))
|
||||
} else {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let (reason, message) = classify_permanent(&body);
|
||||
.map_err(|_| CodexAuthError::RefreshTransient("read response body failed".into()))?
|
||||
{
|
||||
// Content-Length can be absent or untrustworthy. Bound actual bytes too,
|
||||
// before copying a chunk into the accumulated body.
|
||||
if chunk.len() > limits.max_body_bytes.saturating_sub(bytes.len()) {
|
||||
return Err(body_too_large());
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
if status.is_success() {
|
||||
let tokens: RefreshResponse = serde_json::from_slice(&bytes).map_err(|_| {
|
||||
// Do not expose untrusted body values (which can contain secrets).
|
||||
CodexAuthError::RefreshTransient("decode response failed".into())
|
||||
})?;
|
||||
if tokens.access_token.as_deref().is_none_or(str::is_empty)
|
||||
|| tokens.id_token.as_deref().is_some_and(str::is_empty)
|
||||
|| tokens.refresh_token.as_deref().is_some_and(str::is_empty)
|
||||
{
|
||||
return Err(CodexAuthError::RefreshTransient(
|
||||
"incomplete token response".into(),
|
||||
));
|
||||
}
|
||||
Ok(tokens)
|
||||
} else if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
let (reason, message) = classify_permanent(&String::from_utf8_lossy(&bytes));
|
||||
Err(CodexAuthError::RefreshPermanent { reason, message })
|
||||
} else {
|
||||
// Error bodies are consumed within the same limits but never echoed.
|
||||
Err(CodexAuthError::RefreshTransient(format!(
|
||||
"{status}: {body}"
|
||||
"refresh endpoint returned {status}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn response_with_timeout(
|
||||
future: impl std::future::Future<Output = Result<reqwest::Response, reqwest::Error>>,
|
||||
timeout: Duration,
|
||||
) -> Result<reqwest::Response, CodexAuthError> {
|
||||
tokio::time::timeout(timeout, future)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
CodexAuthError::RefreshTransient(format!(
|
||||
"oauth_token_refresh timed out after {}s",
|
||||
timeout.as_secs()
|
||||
))
|
||||
})?
|
||||
.map_err(|e| CodexAuthError::RefreshTransient(format!("send: {e}")))
|
||||
fn body_too_large() -> CodexAuthError {
|
||||
CodexAuthError::RefreshTransient("response body exceeds size limit".into())
|
||||
}
|
||||
|
||||
fn classify_permanent(body: &str) -> (PermanentReason, String) {
|
||||
@@ -99,7 +158,7 @@ fn classify_permanent(body: &str) -> (PermanentReason, String) {
|
||||
PermanentReason::Expired => "Your refresh token has expired".to_string(),
|
||||
PermanentReason::Reused => "Your refresh token was already used".to_string(),
|
||||
PermanentReason::Revoked => "Your refresh token was revoked".to_string(),
|
||||
PermanentReason::Other => format!("Unknown 401 from refresh endpoint: {body}"),
|
||||
PermanentReason::Other => "Unknown 401 from refresh endpoint".to_string(),
|
||||
};
|
||||
(reason, message)
|
||||
}
|
||||
@@ -126,23 +185,6 @@ fn extract_error_code(body: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_response_timeout_is_transient() {
|
||||
let err = match response_with_timeout(
|
||||
std::future::pending::<Result<reqwest::Response, reqwest::Error>>(),
|
||||
Duration::from_millis(5),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("expected refresh timeout"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(
|
||||
matches!(err, CodexAuthError::RefreshTransient(message) if message.contains("timed out"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_expired() {
|
||||
let body = r#"{"error":{"code":"refresh_token_expired"}}"#;
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
//! Real AuthProvider calls against owned auth files and a loopback HTTP peer.
|
||||
//! Gates place callers at response-body / reload boundaries; paused Tokio time
|
||||
//! advances the injected HTTP deadline only after headers have been received.
|
||||
use super::tests::{make_jwt, write_auth};
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::time::Duration as StdDuration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{Notify, mpsc};
|
||||
use tokio::task::{JoinHandle, JoinSet};
|
||||
|
||||
struct Reply {
|
||||
status: u16,
|
||||
body: Vec<u8>,
|
||||
chunked: bool,
|
||||
gate: Option<Arc<Notify>>,
|
||||
truncate: bool,
|
||||
}
|
||||
|
||||
impl Reply {
|
||||
fn body(status: u16, body: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: body.into(),
|
||||
chunked: false,
|
||||
gate: None,
|
||||
truncate: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn gated(status: u16, body: impl Into<Vec<u8>>, gate: &Arc<Notify>) -> Self {
|
||||
Self {
|
||||
gate: Some(gate.clone()),
|
||||
..Self::body(status, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Stub {
|
||||
endpoint: String,
|
||||
requests: mpsc::UnboundedReceiver<Value>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Drop for Stub {
|
||||
fn drop(&mut self) {
|
||||
// Dropping the server's JoinSet also aborts stalled connection handlers.
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl Stub {
|
||||
async fn start(replies: Vec<Reply>) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let endpoint = format!("http://{}/oauth/token", listener.local_addr().unwrap());
|
||||
let (tx, requests) = mpsc::unbounded_channel();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut handlers = JoinSet::new();
|
||||
for reply in replies {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
let tx = tx.clone();
|
||||
handlers.spawn(async move {
|
||||
let mut request = Vec::new();
|
||||
let header_end = loop {
|
||||
let byte = stream.read_u8().await.unwrap();
|
||||
request.push(byte);
|
||||
if request.ends_with(b"\r\n\r\n") { break request.len(); }
|
||||
assert!(request.len() < 16 * 1024, "fixture request headers too large");
|
||||
};
|
||||
let headers = std::str::from_utf8(&request).unwrap();
|
||||
assert!(headers.starts_with("POST /oauth/token HTTP/1.1\r\n"));
|
||||
let len: usize = headers.lines().find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
|
||||
}).unwrap();
|
||||
request.resize(header_end + len, 0);
|
||||
stream.read_exact(&mut request[header_end..]).await.unwrap();
|
||||
tx.send(serde_json::from_slice(&request[header_end..]).unwrap()).unwrap();
|
||||
let framing = if reply.chunked {
|
||||
"Transfer-Encoding: chunked".to_string()
|
||||
} else {
|
||||
format!("Content-Length: {}", reply.body.len())
|
||||
};
|
||||
stream.write_all(format!(
|
||||
"HTTP/1.1 {} Fixture\r\nContent-Type: application/json\r\n{framing}\r\nConnection: close\r\n\r\n", reply.status
|
||||
).as_bytes()).await.unwrap();
|
||||
if let Some(gate) = reply.gate { gate.notified().await; }
|
||||
let body = if reply.truncate { &reply.body[..reply.body.len() / 2] } else { &reply.body[..] };
|
||||
if reply.chunked {
|
||||
for chunk in body.chunks(512) {
|
||||
let _ = stream.write_all(format!("{:x}\r\n", chunk.len()).as_bytes()).await;
|
||||
let _ = stream.write_all(chunk).await;
|
||||
let _ = stream.write_all(b"\r\n").await;
|
||||
}
|
||||
let _ = stream.write_all(b"0\r\n\r\n").await;
|
||||
} else {
|
||||
let _ = stream.write_all(body).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
while let Some(result) = handlers.join_next().await {
|
||||
result.unwrap();
|
||||
}
|
||||
});
|
||||
Self {
|
||||
endpoint,
|
||||
requests,
|
||||
task,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rotated_body() -> Vec<u8> {
|
||||
serde_json::to_vec(&json!({
|
||||
"access_token": make_jwt(json!({"exp": Utc::now().timestamp() + 3600})),
|
||||
"refresh_token": "fixture-rotated-secret",
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn provider(dir: &std::path::Path, stub: &Stub, started: &Arc<Notify>) -> Arc<CodexAuthProvider> {
|
||||
let mut provider = CodexAuthProvider::new(dir.to_path_buf())
|
||||
.with_refresh_endpoint(&stub.endpoint)
|
||||
.with_http_client(reqwest::Client::builder().no_proxy().build().unwrap());
|
||||
provider.refresh_limits = refresh::RefreshLimits {
|
||||
timeout: StdDuration::from_secs(10),
|
||||
max_body_bytes: 1024,
|
||||
body_started: Some(started.clone()),
|
||||
};
|
||||
Arc::new(provider)
|
||||
}
|
||||
|
||||
async fn bounded<F: std::future::Future>(future: F) -> F::Output {
|
||||
tokio::time::timeout(StdDuration::from_secs(5), future)
|
||||
.await
|
||||
.expect("fixture/caller did not progress")
|
||||
}
|
||||
|
||||
fn assert_rotated(path: &std::path::Path, headers: &[(HeaderName, HeaderValue)]) {
|
||||
let value: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap();
|
||||
assert_eq!(value["tokens"]["refresh_token"], "fixture-rotated-secret");
|
||||
assert_eq!(value["fixture_extra"], "preserve me");
|
||||
let auth = headers
|
||||
.iter()
|
||||
.find(|(name, _)| name == "authorization")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
auth.1.to_str().unwrap(),
|
||||
format!(
|
||||
"Bearer {}",
|
||||
value["tokens"]["access_token"].as_str().unwrap()
|
||||
)
|
||||
);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
assert_eq!(
|
||||
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn stale_auth(dir: &std::path::Path) -> PathBuf {
|
||||
let path = write_auth(
|
||||
dir,
|
||||
Utc::now().timestamp() - 60,
|
||||
false,
|
||||
"fixture-old-secret",
|
||||
);
|
||||
let mut value: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
value["fixture_extra"] = json!("preserve me");
|
||||
std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stalled_success_and_error_bodies_timeout_without_persist_and_release_waiting_caller() {
|
||||
for status in [200, 401, 503] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let gate = Arc::new(Notify::new());
|
||||
let started = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![
|
||||
Reply::gated(status, b"unfinished".to_vec(), &gate),
|
||||
Reply::body(200, rotated_body()),
|
||||
])
|
||||
.await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let first = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await; // provider has received headers and is consuming the body
|
||||
let request = bounded(stub.requests.recv()).await.unwrap();
|
||||
assert_eq!(request["refresh_token"], "fixture-old-secret");
|
||||
let second = provider.headers();
|
||||
tokio::pin!(second);
|
||||
assert!(futures::poll!(&mut second).is_pending());
|
||||
assert!(provider.state.try_lock().is_err());
|
||||
assert!(
|
||||
stub.requests.try_recv().is_err(),
|
||||
"waiter must not refresh concurrently"
|
||||
);
|
||||
tokio::time::pause();
|
||||
tokio::time::advance(StdDuration::from_secs(11)).await;
|
||||
let error = first.await.unwrap().unwrap_err();
|
||||
tokio::time::resume();
|
||||
assert!(error.to_string().contains("timed out"), "{error}");
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
let headers = bounded(second).await.unwrap();
|
||||
assert_rotated(&path, &headers);
|
||||
let retry = bounded(stub.requests.recv()).await.unwrap();
|
||||
assert_eq!(retry["refresh_token"], "fixture-old-secret");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_malformed_and_incomplete_bodies_do_not_persist_and_allow_later_refresh() {
|
||||
let mut cases = vec![
|
||||
(Reply::body(200, vec![b'x'; 1025]), "size limit"),
|
||||
(
|
||||
Reply {
|
||||
chunked: true,
|
||||
..Reply::body(200, vec![b'x'; 1025])
|
||||
},
|
||||
"size limit",
|
||||
),
|
||||
(
|
||||
Reply {
|
||||
chunked: true,
|
||||
..Reply::body(401, vec![b'x'; 1025])
|
||||
},
|
||||
"size limit",
|
||||
),
|
||||
(Reply::body(503, vec![b'x'; 1025]), "size limit"),
|
||||
(
|
||||
Reply::body(
|
||||
200,
|
||||
br#"{"access_token": {"secret":"fixture-body-secret"}}"#.to_vec(),
|
||||
),
|
||||
"decode",
|
||||
),
|
||||
(Reply::body(200, b"{".to_vec()), "decode"),
|
||||
(Reply::body(200, b"{}".to_vec()), "incomplete"),
|
||||
(
|
||||
Reply::body(
|
||||
200,
|
||||
br#"{"access_token":"","refresh_token":"fixture-body-secret"}"#.to_vec(),
|
||||
),
|
||||
"incomplete",
|
||||
),
|
||||
(
|
||||
Reply {
|
||||
truncate: true,
|
||||
..Reply::body(200, rotated_body())
|
||||
},
|
||||
"read response body",
|
||||
),
|
||||
(
|
||||
Reply {
|
||||
truncate: true,
|
||||
..Reply::body(401, b"fixture-body-secret".to_vec())
|
||||
},
|
||||
"read response body",
|
||||
),
|
||||
(
|
||||
Reply::body(401, b"fixture-body-secret".to_vec()),
|
||||
"codex login",
|
||||
),
|
||||
(Reply::body(503, b"fixture-body-secret".to_vec()), "503"),
|
||||
];
|
||||
for (reply, expected) in cases.drain(..) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let started = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![reply, Reply::body(200, rotated_body())]).await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let error = bounded(provider.headers()).await.unwrap_err();
|
||||
let message = error.to_string();
|
||||
assert!(message.contains(expected), "expected {expected}: {message}");
|
||||
assert!(!message.contains("fixture-body-secret"));
|
||||
assert!(!message.contains("fixture-old-secret"));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
let headers = bounded(provider.headers()).await.unwrap();
|
||||
assert_rotated(&path, &headers);
|
||||
for _ in 0..2 {
|
||||
assert_eq!(
|
||||
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
|
||||
"fixture-old-secret"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_body_wait_leaves_auth_unchanged_and_releases_waiting_caller() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
let started = Arc::new(Notify::new());
|
||||
let gate = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![
|
||||
Reply::gated(200, rotated_body(), &gate),
|
||||
Reply::body(200, rotated_body()),
|
||||
])
|
||||
.await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let first = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await;
|
||||
bounded(stub.requests.recv()).await.unwrap();
|
||||
let second = provider.headers();
|
||||
tokio::pin!(second);
|
||||
assert!(futures::poll!(&mut second).is_pending());
|
||||
assert!(stub.requests.try_recv().is_err());
|
||||
first.abort();
|
||||
assert!(first.await.unwrap_err().is_cancelled());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert_rotated(&path, &bounded(second).await.unwrap());
|
||||
assert_eq!(
|
||||
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
|
||||
"fixture-old-secret"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shared_callers_refresh_once_and_use_persisted_rotated_tokens() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let started = Arc::new(Notify::new());
|
||||
let gate = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![Reply::gated(200, rotated_body(), &gate)]).await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let first = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await;
|
||||
let second = provider.headers();
|
||||
tokio::pin!(second);
|
||||
assert!(futures::poll!(&mut second).is_pending());
|
||||
assert_eq!(
|
||||
bounded(stub.requests.recv()).await.unwrap()["refresh_token"],
|
||||
"fixture-old-secret"
|
||||
);
|
||||
let mut external: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
external["concurrent_extra"] = json!("merge me");
|
||||
std::fs::write(&path, serde_json::to_vec(&external).unwrap()).unwrap();
|
||||
gate.notify_one();
|
||||
let first_headers = bounded(first).await.unwrap().unwrap();
|
||||
let second_headers = bounded(second).await.unwrap();
|
||||
assert_eq!(first_headers, second_headers);
|
||||
assert_rotated(&path, &second_headers);
|
||||
let persisted: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(persisted["concurrent_extra"], "merge me");
|
||||
assert!(
|
||||
stub.requests.try_recv().is_err(),
|
||||
"single-flight must make only one request"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_switch_before_guarded_reload_skips_old_account_refresh() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let barrier = Arc::new(tokio::sync::Barrier::new(2));
|
||||
let mut provider = CodexAuthProvider::new(dir.path().to_path_buf());
|
||||
let mut stub = Stub::start(vec![]).await;
|
||||
provider.refresh_endpoint = stub.endpoint.clone();
|
||||
provider.before_guarded_reload = Some(barrier.clone());
|
||||
let task = tokio::spawn(async move { provider.headers().await });
|
||||
bounded(barrier.wait()).await;
|
||||
let mut switched: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
switched["tokens"]["account_id"] = json!("switched-account");
|
||||
switched["tokens"]["refresh_token"] = json!("fixture-switched-secret");
|
||||
std::fs::write(&path, serde_json::to_vec(&switched).unwrap()).unwrap();
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
barrier.wait().await;
|
||||
let headers = bounded(task).await.unwrap().unwrap();
|
||||
assert!(
|
||||
headers
|
||||
.iter()
|
||||
.any(|(name, value)| name == "chatgpt-account-id" && value == "switched-account")
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert!(stub.requests.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn account_or_token_switch_during_refresh_is_not_overwritten_by_old_response() {
|
||||
for account_changed in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = stale_auth(dir.path());
|
||||
let started = Arc::new(Notify::new());
|
||||
let gate = Arc::new(Notify::new());
|
||||
let mut stub = Stub::start(vec![Reply::gated(200, rotated_body(), &gate)]).await;
|
||||
let provider = provider(dir.path(), &stub, &started);
|
||||
let task = tokio::spawn({
|
||||
let provider = provider.clone();
|
||||
async move { provider.headers().await }
|
||||
});
|
||||
bounded(started.notified()).await;
|
||||
bounded(stub.requests.recv()).await.unwrap();
|
||||
let mut switched: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
if account_changed {
|
||||
switched["tokens"]["account_id"] = json!("switched-account");
|
||||
} else {
|
||||
switched["tokens"]["refresh_token"] = json!("fixture-switched-secret");
|
||||
}
|
||||
switched["tokens"]["access_token"] =
|
||||
json!(make_jwt(json!({"exp": Utc::now().timestamp() + 7200})));
|
||||
std::fs::write(&path, serde_json::to_vec(&switched).unwrap()).unwrap();
|
||||
let original = std::fs::read(&path).unwrap();
|
||||
gate.notify_one();
|
||||
let headers = bounded(task).await.unwrap().unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
let expected = format!(
|
||||
"Bearer {}",
|
||||
switched["tokens"]["access_token"].as_str().unwrap()
|
||||
);
|
||||
assert!(headers.iter().any(|(name, value)| name == "authorization" && value.to_str().unwrap() == expected));
|
||||
assert_eq!(bounded(provider.headers()).await.unwrap(), headers);
|
||||
assert!(stub.requests.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -2873,3 +2873,737 @@ async fn post_tool_stop_terminalizes_started_parallel_siblings_before_returning(
|
||||
assert!(matches!(next, EngineRunExit::Finished));
|
||||
}
|
||||
}
|
||||
|
||||
// The same policy barrier is exercised through the real response-complete,
|
||||
// provider fallback, interceptor fallback, and early-dispatch engine paths.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum PolicyRoute {
|
||||
AfterResponse,
|
||||
ProviderFallback,
|
||||
InterceptorFallback,
|
||||
Early,
|
||||
}
|
||||
|
||||
impl PolicyRoute {
|
||||
fn completion_support(self) -> ToolCallCompletionSupport {
|
||||
match self {
|
||||
Self::ProviderFallback => ToolCallCompletionSupport::ResponseComplete,
|
||||
_ => ToolCallCompletionSupport::PerBlock,
|
||||
}
|
||||
}
|
||||
|
||||
fn configure(self, engine: &mut Engine<ControlledStreamClient>) {
|
||||
engine.set_max_turns(Some(1));
|
||||
if matches!(self, Self::AfterResponse) {
|
||||
engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::AfterResponse);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NotifyOnDrop(Arc<tokio::sync::Notify>);
|
||||
|
||||
impl Drop for NotifyOnDrop {
|
||||
fn drop(&mut self) {
|
||||
self.0.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PolicyBarrier {
|
||||
phase: InterceptorPhase,
|
||||
early_compatible: bool,
|
||||
entered: Arc<tokio::sync::Notify>,
|
||||
release: Arc<tokio::sync::Notify>,
|
||||
invocations: Arc<AtomicUsize>,
|
||||
continues: Arc<AtomicUsize>,
|
||||
retired: Arc<tokio::sync::Notify>,
|
||||
// Enqueue from the policy's final poll, after select has polled signals.
|
||||
// This deterministically exercises the policy-completion/ownership race.
|
||||
signal_on_continue: Option<tokio::sync::mpsc::Sender<()>>,
|
||||
}
|
||||
|
||||
impl PolicyBarrier {
|
||||
fn new(phase: InterceptorPhase, route: PolicyRoute) -> Self {
|
||||
Self {
|
||||
phase,
|
||||
early_compatible: !matches!(route, PolicyRoute::InterceptorFallback),
|
||||
entered: Arc::new(tokio::sync::Notify::new()),
|
||||
release: Arc::new(tokio::sync::Notify::new()),
|
||||
invocations: Arc::new(AtomicUsize::new(0)),
|
||||
continues: Arc::new(AtomicUsize::new(0)),
|
||||
retired: Arc::new(tokio::sync::Notify::new()),
|
||||
signal_on_continue: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait(&self) {
|
||||
let _retired = NotifyOnDrop(self.retired.clone());
|
||||
self.invocations.fetch_add(1, Ordering::SeqCst);
|
||||
self.entered.notify_one();
|
||||
self.release.notified().await;
|
||||
if let Some(signal) = &self.signal_on_continue {
|
||||
signal.try_send(()).unwrap();
|
||||
}
|
||||
self.continues.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor for PolicyBarrier {
|
||||
fn supports_early_tool_dispatch(&self) -> bool {
|
||||
self.early_compatible
|
||||
}
|
||||
|
||||
async fn pre_tool_call(
|
||||
&self,
|
||||
_info: &mut ToolCallInfo<'_>,
|
||||
) -> InterceptorResult<PreToolAction> {
|
||||
if self.phase == InterceptorPhase::PreToolCall {
|
||||
self.wait().await;
|
||||
}
|
||||
Ok(PreToolAction::Continue)
|
||||
}
|
||||
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_context: AssistantTurnEndContext<'_>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
if self.phase == InterceptorPhase::AssistantTurnEnd {
|
||||
self.wait().await;
|
||||
}
|
||||
Ok(TurnEndAction::Finish)
|
||||
}
|
||||
}
|
||||
|
||||
fn send_policy_response(tx: &tokio::sync::mpsc::UnboundedSender<Result<Event, ClientError>>) {
|
||||
for event in [
|
||||
Event::tool_use_start(0, "policy_call", "policy_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::usage(10, 3),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_policy_interruption(exit: &EngineRunExit, pause: bool) {
|
||||
if pause {
|
||||
assert!(matches!(exit, EngineRunExit::Paused), "{exit:?}");
|
||||
} else {
|
||||
assert!(
|
||||
matches!(
|
||||
exit,
|
||||
EngineRunExit::Interrupted(RunInterruptionReason::Cancelled)
|
||||
),
|
||||
"{exit:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn call_and_result_count(history: &History) -> (usize, usize) {
|
||||
(
|
||||
history
|
||||
.items()
|
||||
.filter(
|
||||
|item| matches!(item, Item::ToolCall { call_id, .. } if call_id == "policy_call"),
|
||||
)
|
||||
.count(),
|
||||
history
|
||||
.items()
|
||||
.filter(
|
||||
|item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "policy_call"),
|
||||
)
|
||||
.count(),
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn response_complete_pending_policies_interrupt_before_release_and_preserve_not_started() {
|
||||
for route in [
|
||||
PolicyRoute::AfterResponse,
|
||||
PolicyRoute::ProviderFallback,
|
||||
PolicyRoute::InterceptorFallback,
|
||||
] {
|
||||
for phase in [
|
||||
InterceptorPhase::PreToolCall,
|
||||
InterceptorPhase::AssistantTurnEnd,
|
||||
] {
|
||||
for pause in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(route.completion_support());
|
||||
let mut engine = Engine::new(client);
|
||||
route.configure(&mut engine);
|
||||
let policy = PolicyBarrier::new(phase, route);
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let tool = SlowTool::new("policy_tool", 0);
|
||||
engine.register_tool(tool.definition());
|
||||
let usage = Arc::new(AtomicUsize::new(0));
|
||||
let usage_probe = usage.clone();
|
||||
engine.on_usage(move |_| {
|
||||
usage_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
let signal = if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
};
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
send_policy_response(&tx);
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("policy entered");
|
||||
signal.send(()).await.unwrap();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run).await
|
||||
.unwrap_or_else(|_| panic!("{route:?}/{phase:?}/pause={pause}: interruption waited for policy release"))
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
assert_eq!(call_and_result_count(&history), (1, 0));
|
||||
assert!(history.items().any(|item| matches!(
|
||||
item,
|
||||
Item::ToolCall {
|
||||
execution_id: None,
|
||||
status: None | Some(agen::llm_client::ItemStatus::Completed),
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert_eq!(usage.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(probe.invocations.load(Ordering::SeqCst), 1);
|
||||
probe.release.notify_one(); // late permission cannot create a task
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(tool.call_count(), 0);
|
||||
|
||||
// Recreate both history and engine as a restore boundary. Only
|
||||
// proven NotStarted is allowed to run, with normal pre-tool policy.
|
||||
let mut restored_history: History =
|
||||
serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap();
|
||||
let mut restored =
|
||||
Engine::new(MockLlmClient::new(vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})]));
|
||||
restored.set_max_turns(Some(1));
|
||||
restored.register_tool(tool.definition());
|
||||
let _ = restored.resume(&mut restored_history).await;
|
||||
assert_eq!(tool.call_count(), 1);
|
||||
assert_eq!(call_and_result_count(&restored_history), (1, 1));
|
||||
assert_eq!(usage.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn policy_final_poll_signal_prevents_provider_ownership_even_with_continue() {
|
||||
for route in [
|
||||
PolicyRoute::AfterResponse,
|
||||
PolicyRoute::ProviderFallback,
|
||||
PolicyRoute::InterceptorFallback,
|
||||
PolicyRoute::Early,
|
||||
] {
|
||||
for pause in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(route.completion_support());
|
||||
let mut engine = Engine::new(client);
|
||||
route.configure(&mut engine);
|
||||
let mut policy = PolicyBarrier::new(InterceptorPhase::PreToolCall, route);
|
||||
policy.signal_on_continue = Some(if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
});
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let tool = SlowTool::new("policy_tool", 0);
|
||||
engine.register_tool(tool.definition());
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
send_policy_response(&tx);
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("policy entered");
|
||||
probe.release.notify_one();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("final-poll signal processed")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(probe.invocations.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
tool.call_count(),
|
||||
0,
|
||||
"{route:?}/pause={pause}: Continue bypassed queued interruption"
|
||||
);
|
||||
assert_eq!(call_and_result_count(&history), (1, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Provider owns the terminal channel. Cancel acknowledges receipt but does not
|
||||
// manufacture a result, so tests can release a real terminal output or deliver
|
||||
// it after the engine's confirmation deadline (without a live side effect).
|
||||
#[derive(Clone)]
|
||||
struct PolicyOwnedTool {
|
||||
starts: Arc<AtomicUsize>,
|
||||
started: Arc<tokio::sync::Notify>,
|
||||
cancel_requested: Arc<tokio::sync::Notify>,
|
||||
terminal: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Receiver<ToolOutput>>>>,
|
||||
retired: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for PolicyOwnedTool {
|
||||
async fn execute(&self, _: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
|
||||
let _retired = NotifyOnDrop(self.retired.clone());
|
||||
self.starts.fetch_add(1, Ordering::SeqCst);
|
||||
self.started.notify_one();
|
||||
Ok(self.terminal.lock().await.take().unwrap().await.unwrap())
|
||||
}
|
||||
async fn cancel(&self, _: &str) -> Result<(), ToolError> {
|
||||
self.cancel_requested.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_turn_end_interrupts_drain_owned_early_attempt_and_fence_late_result_on_restore() {
|
||||
for pause in [false, true] {
|
||||
for late in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
PolicyRoute::Early.configure(&mut engine);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::ZERO,
|
||||
cancellation_request_timeout: Duration::from_secs(1),
|
||||
terminal_confirmation_timeout: if late {
|
||||
Duration::ZERO
|
||||
} else {
|
||||
Duration::from_secs(2)
|
||||
},
|
||||
});
|
||||
let policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let tool = PolicyOwnedTool {
|
||||
starts: Arc::new(AtomicUsize::new(0)),
|
||||
started: Arc::new(tokio::sync::Notify::new()),
|
||||
cancel_requested: Arc::new(tokio::sync::Notify::new()),
|
||||
terminal: Arc::new(tokio::sync::Mutex::new(Some(terminal_rx))),
|
||||
retired: Arc::new(tokio::sync::Notify::new()),
|
||||
};
|
||||
let definition: ToolDefinition = {
|
||||
let tool = tool.clone();
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new("policy_tool")
|
||||
.input_schema(serde_json::json!({"type":"object"})),
|
||||
Arc::new(tool.clone()) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
};
|
||||
engine.register_tool(definition.clone());
|
||||
let committed = Arc::new(AtomicUsize::new(0));
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_history_append(move |item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
commit_probe.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let published = Arc::new(AtomicUsize::new(0));
|
||||
let publish_probe = published.clone();
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_tool_result(move |_| {
|
||||
assert_eq!(
|
||||
commit_probe.load(Ordering::SeqCst),
|
||||
1,
|
||||
"commit before publication"
|
||||
);
|
||||
publish_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
let usage = Arc::new(AtomicUsize::new(0));
|
||||
let usage_probe = usage.clone();
|
||||
engine.on_usage(move |_| {
|
||||
usage_probe.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
let signal = if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
};
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
// Wait for ownership before completing the assistant response.
|
||||
for event in [
|
||||
Event::tool_use_start(0, "policy_call", "policy_tool"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(2), tool.started.notified())
|
||||
.await
|
||||
.expect("early provider started");
|
||||
tx.send(Ok(Event::usage(10, 3))).unwrap();
|
||||
tx.send(Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("turn-end policy pending");
|
||||
signal.send(()).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), tool.cancel_requested.notified())
|
||||
.await
|
||||
.expect("owned provider cancellation progresses before policy release");
|
||||
let terminal_tx = if late {
|
||||
Some(terminal_tx)
|
||||
} else {
|
||||
terminal_tx
|
||||
.send(ToolOutput::from("confirmed after interruption".to_string()))
|
||||
.unwrap();
|
||||
None
|
||||
};
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("terminalization does not await turn-end policy")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(call_and_result_count(&history), (1, 1));
|
||||
assert_eq!(committed.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(usage.load(Ordering::SeqCst), 1);
|
||||
assert!(history.items().any(|item| matches!(
|
||||
item,
|
||||
Item::ToolCall {
|
||||
execution_id: Some(_),
|
||||
status: Some(agen::llm_client::ItemStatus::InProgress),
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert!(history.items().any(|item| matches!(item, Item::ToolResult { disposition, .. } if *disposition == if late { ToolResultDisposition::OutcomeUnknown } else { ToolResultDisposition::Success })));
|
||||
if let Some(terminal_tx) = terminal_tx {
|
||||
tokio::time::timeout(Duration::from_secs(2), tool.retired.notified())
|
||||
.await
|
||||
.expect("forced-close retired the owned receiver");
|
||||
// Force-close retires the receiver; a provider's late completion
|
||||
// cannot reenter history or replace OutcomeUnknown.
|
||||
assert!(
|
||||
terminal_tx
|
||||
.send(ToolOutput::from("late completion".to_string()))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
probe.release.notify_one();
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 0);
|
||||
let original_items = history.items().cloned().collect::<Vec<_>>();
|
||||
let mut resumed_history = history.clone();
|
||||
let mut resumed_engine = output.engine;
|
||||
let _ = resumed_engine.resume(&mut resumed_history).await;
|
||||
assert_eq!(
|
||||
tool.starts.load(Ordering::SeqCst),
|
||||
1,
|
||||
"same-engine resume cannot rerun a terminal attempt"
|
||||
);
|
||||
assert_eq!(call_and_result_count(&resumed_history), (1, 1));
|
||||
let mut restored_history: History =
|
||||
serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap();
|
||||
let restored_client = MockLlmClient::new(vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})]);
|
||||
let request_probe = restored_client.clone();
|
||||
let mut restored = Engine::new(restored_client);
|
||||
restored.register_tool(definition);
|
||||
restored.set_max_turns(Some(1));
|
||||
let _ = restored.resume(&mut restored_history).await;
|
||||
let requests = request_probe.requests();
|
||||
assert_eq!(requests.len(), 1);
|
||||
let replay = requests[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
Item::ToolCall { call_id, .. } => Some(format!("call:{call_id}")),
|
||||
Item::ToolResult { call_id, .. } => Some(format!("result:{call_id}")),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(replay, ["call:policy_call", "result:policy_call"]);
|
||||
assert_eq!(tool.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(call_and_result_count(&restored_history), (1, 1));
|
||||
assert_eq!(
|
||||
restored_history
|
||||
.items()
|
||||
.take(original_items.len())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>(),
|
||||
original_items,
|
||||
"restore must not rewrite original history"
|
||||
);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_turn_end_pause_preserves_natural_safe_boundary_without_cancelling_started_tool() {
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
PolicyRoute::Early.configure(&mut engine);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::from_secs(2),
|
||||
..ToolExecutionPolicy::default()
|
||||
});
|
||||
let policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let tool = SafePauseTool::new();
|
||||
// Use this tool's actual public name in the stream fixture.
|
||||
engine.register_tool(tool.definition());
|
||||
let pause = engine.pause_sender();
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
for event in [
|
||||
Event::tool_use_start(0, "policy_call", "safe_pause"),
|
||||
Event::tool_input_delta(0, "{}"),
|
||||
Event::tool_use_stop(0),
|
||||
Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
}),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("turn-end entered");
|
||||
pause.send(()).await.unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.retired.notified())
|
||||
.await
|
||||
.expect("pending policy interrupted before tool release");
|
||||
tool.release.notify_one();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("safe boundary collected")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, true);
|
||||
assert_eq!(tool.calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(tool.cancellations.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(call_and_result_count(&history), (1, 1));
|
||||
probe.release.notify_one();
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_end_final_poll_signal_wins_over_finish_without_tools() {
|
||||
for pause in [false, true] {
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
let mut policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
policy.signal_on_continue = Some(if pause {
|
||||
engine.pause_sender()
|
||||
} else {
|
||||
engine.cancel_sender()
|
||||
});
|
||||
let probe = policy.clone();
|
||||
engine.set_interceptor(policy);
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
engine.run(&mut history, "run").await
|
||||
});
|
||||
tx.send(Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), probe.entered.notified())
|
||||
.await
|
||||
.expect("turn-end entered");
|
||||
probe.release.notify_one();
|
||||
let output = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("final-poll signal processed")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, pause);
|
||||
assert_eq!(probe.continues.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_end_pause_deadline_reclaims_started_sibling_while_post_policy_is_pending() {
|
||||
struct TurnAndPostBarrier {
|
||||
turn: PolicyBarrier,
|
||||
post: PolicyBarrier,
|
||||
}
|
||||
#[async_trait]
|
||||
impl Interceptor for TurnAndPostBarrier {
|
||||
fn supports_early_tool_dispatch(&self) -> bool {
|
||||
true
|
||||
}
|
||||
async fn on_assistant_turn_end(
|
||||
&self,
|
||||
_: AssistantTurnEndContext<'_>,
|
||||
) -> InterceptorResult<TurnEndAction> {
|
||||
self.turn.wait().await;
|
||||
Ok(TurnEndAction::Finish)
|
||||
}
|
||||
async fn post_tool_call(
|
||||
&self,
|
||||
_: &ToolResultInfo<'_>,
|
||||
) -> InterceptorResult<PostToolAction> {
|
||||
self.post.wait().await;
|
||||
Ok(PostToolAction::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock);
|
||||
let mut engine = Engine::new(client);
|
||||
engine.set_tool_execution_policy(ToolExecutionPolicy {
|
||||
pause_safe_boundary_timeout: Duration::from_millis(500),
|
||||
cancellation_request_timeout: Duration::from_secs(1),
|
||||
terminal_confirmation_timeout: Duration::from_secs(2),
|
||||
});
|
||||
let turn = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early);
|
||||
let post = PolicyBarrier::new(InterceptorPhase::PostToolCall, PolicyRoute::Early);
|
||||
engine.set_interceptor(TurnAndPostBarrier {
|
||||
turn: turn.clone(),
|
||||
post: post.clone(),
|
||||
});
|
||||
let completed = BarrierTool::new("completed_before_pause");
|
||||
engine.register_tool(completed.definition());
|
||||
let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel();
|
||||
let sibling = PolicyOwnedTool {
|
||||
starts: Arc::new(AtomicUsize::new(0)),
|
||||
started: Arc::new(tokio::sync::Notify::new()),
|
||||
cancel_requested: Arc::new(tokio::sync::Notify::new()),
|
||||
terminal: Arc::new(tokio::sync::Mutex::new(Some(terminal_rx))),
|
||||
retired: Arc::new(tokio::sync::Notify::new()),
|
||||
};
|
||||
let definition: ToolDefinition = {
|
||||
let sibling = sibling.clone();
|
||||
Arc::new(move || {
|
||||
(
|
||||
ToolMeta::new("pending_sibling").input_schema(serde_json::json!({"type":"object"})),
|
||||
Arc::new(sibling.clone()) as Arc<dyn Tool>,
|
||||
)
|
||||
})
|
||||
};
|
||||
engine.register_tool(definition.clone());
|
||||
let committed = Arc::new(AtomicUsize::new(0));
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_history_append(move |item| {
|
||||
if matches!(item, Item::ToolResult { .. }) {
|
||||
commit_probe.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
let published = Arc::new(AtomicUsize::new(0));
|
||||
let publish_probe = published.clone();
|
||||
let commit_probe = committed.clone();
|
||||
engine.on_tool_result(move |_| {
|
||||
let n = publish_probe.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
assert_eq!(commit_probe.load(Ordering::SeqCst), n);
|
||||
});
|
||||
let pause = engine.pause_sender();
|
||||
let run = tokio::spawn(async move {
|
||||
let mut history = History::new();
|
||||
let output = engine.run(&mut history, "run").await;
|
||||
(output, history)
|
||||
});
|
||||
for (index, id, name) in [
|
||||
(0, "policy_call", "completed_before_pause"),
|
||||
(1, "sibling_call", "pending_sibling"),
|
||||
] {
|
||||
for event in [
|
||||
Event::tool_use_start(index, id, name),
|
||||
Event::tool_input_delta(index, "{}"),
|
||||
Event::tool_use_stop(index),
|
||||
] {
|
||||
tx.send(Ok(event)).unwrap();
|
||||
}
|
||||
}
|
||||
tokio::time::timeout(Duration::from_secs(2), completed.started.notified())
|
||||
.await
|
||||
.expect("first provider owned");
|
||||
tokio::time::timeout(Duration::from_secs(2), sibling.started.notified())
|
||||
.await
|
||||
.expect("sibling provider owned");
|
||||
tx.send(Ok(Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})))
|
||||
.unwrap();
|
||||
drop(tx);
|
||||
tokio::time::timeout(Duration::from_secs(2), turn.entered.notified())
|
||||
.await
|
||||
.expect("turn-end pending");
|
||||
pause.send(()).await.unwrap(); // only one interruption signal
|
||||
tokio::time::timeout(Duration::from_secs(2), turn.retired.notified())
|
||||
.await
|
||||
.expect("turn-end interrupted");
|
||||
completed.release.notify_one();
|
||||
tokio::time::timeout(Duration::from_secs(2), post.entered.notified())
|
||||
.await
|
||||
.expect("completed result's post policy pending");
|
||||
assert_eq!(committed.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 1);
|
||||
tokio::time::timeout(Duration::from_secs(2), sibling.cancel_requested.notified()).await.expect("original Pause deadline must reach sibling cancellation without releasing either policy");
|
||||
terminal_tx
|
||||
.send(ToolOutput::from("confirmed sibling terminal".to_string()))
|
||||
.unwrap();
|
||||
let (output, history) = tokio::time::timeout(Duration::from_secs(2), run)
|
||||
.await
|
||||
.expect("all owned tools reclaimed")
|
||||
.unwrap();
|
||||
assert_policy_interruption(&output.result, true);
|
||||
assert_eq!(committed.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(published.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(
|
||||
history
|
||||
.items()
|
||||
.filter(|item| matches!(item, Item::ToolResult { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(completed.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(sibling.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(post.invocations.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(post.continues.load(Ordering::SeqCst), 0);
|
||||
turn.release.notify_one();
|
||||
post.release.notify_one();
|
||||
let mut restored_history: History =
|
||||
serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap();
|
||||
let mut restored = Engine::new(MockLlmClient::new(vec![Event::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})]));
|
||||
restored.register_tool(completed.definition());
|
||||
restored.register_tool(definition);
|
||||
restored.set_max_turns(Some(1));
|
||||
let _ = restored.resume(&mut restored_history).await;
|
||||
assert_eq!(completed.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(sibling.starts.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(
|
||||
restored_history
|
||||
.items()
|
||||
.filter(|item| matches!(item, Item::ToolResult { .. }))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -20,6 +20,7 @@ use ticket::{
|
||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||
|
||||
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
|
||||
const UNSUPPORTED_TICKET_STATE_CHANGE: &str = "unsupported legacy Ticket state/close operation; use TicketBackend::update_case with caller-supplied content digest, case state, closure operation key, and replay key (CAS); case updates do not start or stop work";
|
||||
|
||||
/// Workspace-scoped Backend client for Ticket and Objective product state.
|
||||
///
|
||||
@@ -91,6 +92,70 @@ 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,
|
||||
) -> Result<Vec<ticket::WorkspaceTag>, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let request = request.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.workspace_ticket_tag_list(
|
||||
workspace_id,
|
||||
server_api::WorkspaceTicketTagListRequest(request),
|
||||
)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mutate_workspace_tag(
|
||||
&self,
|
||||
request: &ticket::WorkspaceTagMutation,
|
||||
) -> Result<ticket::WorkspaceTag, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let request = request.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.workspace_ticket_tag_mutate(
|
||||
workspace_id,
|
||||
server_api::WorkspaceTicketTagMutationRequest(request),
|
||||
)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_ticket_tags(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
request: &ticket::TicketTagSetRequest,
|
||||
) -> Result<Vec<ticket::WorkspaceTag>, BackendWorkspaceClientError> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
let request = request.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_tags_set(workspace_id, id, server_api::TicketTagsSetRequest(request))
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_tickets(
|
||||
&self,
|
||||
query: &TicketListQuery,
|
||||
@@ -155,76 +220,24 @@ impl BackendWorkspaceProductClient {
|
||||
})
|
||||
}
|
||||
|
||||
fn decision_from_state_change(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
change: &TicketStateChange,
|
||||
) -> Result<ticket::TicketStateUpdate, BackendWorkspaceClientError> {
|
||||
let current = self.show_ticket(id)?;
|
||||
let parse = |state: &str| {
|
||||
ticket::TicketWorkflowState::parse(state).ok_or_else(|| {
|
||||
BackendWorkspaceClientError::InvalidTarget(format!("invalid Ticket state {state}"))
|
||||
})
|
||||
};
|
||||
Ok(ticket::TicketStateUpdate {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
expected_content_digest: ticket::ticket_content_digest(¤t),
|
||||
expected_state: parse(&change.from)?,
|
||||
state: parse(&change.to)?,
|
||||
reason: if change.body.as_str().is_empty() {
|
||||
change.reason.clone()
|
||||
} else {
|
||||
format!("{}\n\n{}", change.reason, change.body.as_str())
|
||||
},
|
||||
references: change.references.clone(),
|
||||
author: change.author.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_ticket_workflow_state(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
change: &TicketStateChange,
|
||||
_id: &TicketIdOrSlug,
|
||||
_change: &TicketStateChange,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
let change = self.decision_from_state_change(id, change)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_workflow_state_set(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketStateChangeRequest(change),
|
||||
)
|
||||
.await
|
||||
})
|
||||
Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn close_ticket(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
resolution: &MarkdownText,
|
||||
_id: &TicketIdOrSlug,
|
||||
_resolution: &MarkdownText,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
let current = self.show_ticket(id)?;
|
||||
let request = ticket::TicketCompletion {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
expected_content_digest: ticket::ticket_content_digest(¤t),
|
||||
expected_state: current.meta.workflow_state,
|
||||
reason: resolution.as_str().to_string(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
};
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_close_record(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketCloseRecordRequest(request),
|
||||
)
|
||||
.await
|
||||
})
|
||||
Err(BackendWorkspaceClientError::InvalidTarget(
|
||||
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn add_ticket_relation(
|
||||
@@ -568,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 {
|
||||
@@ -602,6 +616,88 @@ 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,
|
||||
edit: ticket::TicketPostEdit,
|
||||
) -> ticket::Result<(Ticket, Ticket)> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_record_post_edit(workspace_id, id, server_api::EditTicketPostRequest(edit))
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn list_workspace_tags(
|
||||
&self,
|
||||
request: ticket::TagListRequest,
|
||||
) -> ticket::Result<Vec<ticket::WorkspaceTag>> {
|
||||
BackendWorkspaceProductClient::list_workspace_tags(self, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn mutate_workspace_tag(
|
||||
&self,
|
||||
request: ticket::WorkspaceTagMutation,
|
||||
) -> ticket::Result<ticket::WorkspaceTag> {
|
||||
BackendWorkspaceProductClient::mutate_workspace_tag(self, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_ticket_tags(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
request: ticket::TicketTagSetRequest,
|
||||
) -> ticket::Result<Vec<ticket::WorkspaceTag>> {
|
||||
BackendWorkspaceProductClient::set_ticket_tags(self, &id, &request)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn default_intake_ready_state_change_body(&self, from: &str) -> String {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let from = from.to_string();
|
||||
@@ -664,24 +760,12 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
|
||||
fn add_state_changed(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
_id: TicketIdOrSlug,
|
||||
_change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
let change = self
|
||||
.decision_from_state_change(&id, &change)
|
||||
.map_err(ticket_client_error)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_state_change_add(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketStateChangeRequest(change),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
Err(TicketError::Conflict(
|
||||
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
|
||||
))
|
||||
}
|
||||
|
||||
fn add_intake_summary(
|
||||
@@ -703,59 +787,31 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_state_field(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
field: &str,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
if field != "state" {
|
||||
return Err(TicketError::Conflict(
|
||||
"unsupported Ticket state field".into(),
|
||||
));
|
||||
}
|
||||
let change = self
|
||||
.decision_from_state_change(&id, &change)
|
||||
.map_err(ticket_client_error)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
let field = field.to_string();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_state_field_set(
|
||||
workspace_id,
|
||||
id,
|
||||
field,
|
||||
server_api::TicketStateChangeRequest(change),
|
||||
)
|
||||
.await
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn set_workflow_state(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
self.set_ticket_workflow_state(&id, &change)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn update_state(
|
||||
fn update_case(
|
||||
&self,
|
||||
ticket: &str,
|
||||
request: ticket::TicketStateUpdate,
|
||||
request: ticket::TicketCaseUpdate,
|
||||
) -> ticket::Result<Ticket> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket.to_owned();
|
||||
// Actor identity belongs to authenticated Backend authority, not the caller.
|
||||
let request = server_api::TicketCaseUpdateRequest {
|
||||
expected_content_digest: request.expected_content_digest,
|
||||
expected_state: request.expected_state,
|
||||
expected_closure_operation_key: request.expected_closure_operation_key,
|
||||
operation_key: request.operation_key,
|
||||
action: request.action,
|
||||
explanation: request.explanation,
|
||||
references: request.references,
|
||||
};
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_state_update(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketStateUpdateRequest(request),
|
||||
)
|
||||
.ticket_case_update(workspace_id.clone(), id.clone(), request)
|
||||
.await?;
|
||||
// The case endpoint returns only a summary. Fetch the authoritative
|
||||
// full record rather than fabricating body, events, or case history.
|
||||
client
|
||||
.ticket_record_get(workspace_id, id)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
@@ -795,11 +851,6 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> ticket::Result<()> {
|
||||
self.close_ticket(&id, &resolution)
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn add_ticket_relation(
|
||||
&self,
|
||||
id: TicketIdOrSlug,
|
||||
@@ -941,6 +992,33 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn read_http_request(stream: &mut std::net::TcpStream) -> String {
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
|
||||
.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let mut buffer = [0_u8; 4_096];
|
||||
loop {
|
||||
let bytes = stream.read(&mut buffer).unwrap();
|
||||
assert_ne!(bytes, 0, "incomplete HTTP request");
|
||||
request.extend_from_slice(&buffer[..bytes]);
|
||||
if let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") {
|
||||
let headers = std::str::from_utf8(&request[..header_end]).unwrap();
|
||||
let content_length = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length")
|
||||
.then(|| value.trim().parse::<usize>().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if request.len() >= header_end + 4 + content_length {
|
||||
return String::from_utf8(request).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn one_response_server(
|
||||
status: &str,
|
||||
body: &str,
|
||||
@@ -952,11 +1030,7 @@ mod tests {
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
let handle = thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0_u8; 8_192];
|
||||
let bytes = stream.read(&mut request).unwrap();
|
||||
sender
|
||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
||||
.unwrap();
|
||||
sender.send(read_http_request(&mut stream)).unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
@@ -980,11 +1054,7 @@ mod tests {
|
||||
let handle = thread::spawn(move || {
|
||||
for (status, body) in responses {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut request = vec![0_u8; 16_384];
|
||||
let bytes = stream.read(&mut request).unwrap();
|
||||
sender
|
||||
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
|
||||
.unwrap();
|
||||
sender.send(read_http_request(&mut stream)).unwrap();
|
||||
write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
@@ -997,116 +1067,398 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_state_adapter_uses_snapshot_content_without_overriding_stale_expected_state() {
|
||||
fn post_edit_uses_native_patch_and_returns_server_snapshots_without_reread() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let reference = backend.create(NewTicket::new("Adapter snapshot")).unwrap();
|
||||
let snapshot = backend.show(reference.id.clone().into()).unwrap();
|
||||
let body = serde_json::to_string(&snapshot).unwrap();
|
||||
let (base_url, requests, handle) =
|
||||
response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]);
|
||||
let client =
|
||||
BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token")
|
||||
let mut input = NewTicket::new("Post snapshots");
|
||||
input.body = MarkdownText::new("Original requirements");
|
||||
let reference = backend.create(input).unwrap();
|
||||
let original = backend.show(reference.id.clone().into()).unwrap();
|
||||
let edit = ticket::TicketPostEdit {
|
||||
event_ref: original.events[0].event_ref.clone(),
|
||||
expected_body: original.document.body.clone(),
|
||||
body: MarkdownText::new("Changed requirements"),
|
||||
author: "authenticated-editor".into(),
|
||||
};
|
||||
let expected = backend
|
||||
.edit_post_with_snapshots(reference.id.into(), edit.clone())
|
||||
.unwrap();
|
||||
client
|
||||
.set_ticket_workflow_state(
|
||||
&reference.id.clone().into(),
|
||||
&TicketStateChange::new("ready", "done", "Judgment", "Results in the thread"),
|
||||
let body = serde_json::to_string(&expected).unwrap();
|
||||
let (url, requests, server) = one_response_server("200 OK", &body);
|
||||
let client =
|
||||
BackendWorkspaceProductClient::new_with_access_token(url, "workspace-a", "token")
|
||||
.unwrap();
|
||||
let actual = TicketBackend::edit_post_with_snapshots(
|
||||
&client,
|
||||
TicketIdOrSlug::Query("T-123".into()),
|
||||
edit.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(requests.recv().unwrap().starts_with("GET "));
|
||||
assert_eq!(actual, expected);
|
||||
let request = requests.recv().unwrap();
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert!(request.starts_with("PATCH /api/w/workspace-a/tickets/T-123/posts HTTP/1.1\r\n"));
|
||||
let (_, body) = request.split_once("\r\n\r\n").unwrap();
|
||||
assert_eq!(
|
||||
body["expected_content_digest"],
|
||||
ticket::ticket_content_digest(&snapshot)
|
||||
serde_json::from_str::<ticket::TicketPostEdit>(body).unwrap(),
|
||||
edit
|
||||
);
|
||||
assert_eq!(body["expected_state"], "ready");
|
||||
assert_eq!(body["state"], "done");
|
||||
assert!(!body["operation_key"].as_str().unwrap().is_empty());
|
||||
handle.join().unwrap();
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_close_adapter_forwards_snapshot_cas_and_resolution_as_judgment() {
|
||||
fn legacy_state_and_close_calls_require_explicit_case_cas_without_http() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
format!("http://{}", listener.local_addr().unwrap()),
|
||||
"workspace-a",
|
||||
"token",
|
||||
)
|
||||
.unwrap();
|
||||
let id = TicketIdOrSlug::Query("T-739".into());
|
||||
let change = TicketStateChange::new("open", "closed", "Judgment", "Explanation");
|
||||
let errors = [
|
||||
client
|
||||
.set_ticket_workflow_state(&id, &change)
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
client
|
||||
.close_ticket(&id, &MarkdownText::new("Withdrawn"))
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
TicketBackend::add_state_changed(&client, id, change)
|
||||
.unwrap_err()
|
||||
.to_string(),
|
||||
];
|
||||
for error in errors {
|
||||
assert!(
|
||||
error.contains("unsupported legacy Ticket state/close operation"),
|
||||
"{error}"
|
||||
);
|
||||
assert!(error.contains("TicketBackend::update_case"), "{error}");
|
||||
assert!(error.contains("caller-supplied"), "{error}");
|
||||
assert!(error.contains("CAS"), "{error}");
|
||||
}
|
||||
assert_eq!(
|
||||
listener.accept().unwrap_err().kind(),
|
||||
std::io::ErrorKind::WouldBlock
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serialized_update_case_forwards_tagged_actions_and_returns_full_backend_record() {
|
||||
for action in [
|
||||
serde_json::json!({"action": "close", "reason": "completed"}),
|
||||
serde_json::json!({"action": "close", "reason": {"other": "Custom judgment"}}),
|
||||
serde_json::json!({"action": "correct_closure", "reason": "duplicate"}),
|
||||
serde_json::json!({"action": "reopen"}),
|
||||
] {
|
||||
// SQLite only supplies hermetic response fixtures, never a client fallback.
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let reference = backend
|
||||
.create(NewTicket::new("Close adapter snapshot"))
|
||||
let mut input = NewTicket::new("Full case record");
|
||||
input.body = MarkdownText::new("Full body absent from the endpoint summary");
|
||||
let reference = backend.create(input).unwrap();
|
||||
let tag = backend
|
||||
.mutate_workspace_tag(ticket::WorkspaceTagMutation {
|
||||
operation_key: "classify-create".into(),
|
||||
action: ticket::WorkspaceTagAction::Create {
|
||||
name: "closed".into(),
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
let snapshot = backend.show(reference.id.clone().into()).unwrap();
|
||||
let body = serde_json::to_string(&snapshot).unwrap();
|
||||
let (base_url, requests, handle) =
|
||||
response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]);
|
||||
let client =
|
||||
BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token")
|
||||
backend
|
||||
.set_ticket_tags(
|
||||
reference.id.clone().into(),
|
||||
ticket::TicketTagSetRequest {
|
||||
operation_key: "classify-set".into(),
|
||||
expected_tag_ids: Vec::new(),
|
||||
tag_ids: vec![tag.id.clone()],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
client
|
||||
.close_ticket(
|
||||
&reference.id.into(),
|
||||
&MarkdownText::new("The request was withdrawn"),
|
||||
let mut snapshot = backend.show(reference.id.clone().into()).unwrap();
|
||||
if action["action"] != "close" {
|
||||
snapshot = backend
|
||||
.update_case(
|
||||
&reference.id,
|
||||
ticket::TicketCaseUpdate {
|
||||
expected_content_digest: ticket::ticket_content_digest(&snapshot),
|
||||
expected_state: ticket::TicketState::Open,
|
||||
expected_closure_operation_key: None,
|
||||
operation_key: "initial-close".into(),
|
||||
action: ticket::TicketCaseAction::Close {
|
||||
reason: ticket::TicketClosureReason::Unknown,
|
||||
},
|
||||
explanation: "Initial judgment".into(),
|
||||
references: Vec::new(),
|
||||
author: Some("authenticated-actor".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(requests.recv().unwrap().starts_with("GET "));
|
||||
let request = requests.recv().unwrap();
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body["expected_content_digest"],
|
||||
ticket::ticket_content_digest(&snapshot)
|
||||
);
|
||||
assert_eq!(body["expected_state"], "planning");
|
||||
assert_eq!(body["reason"], "The request was withdrawn");
|
||||
assert!(!body["operation_key"].as_str().unwrap().is_empty());
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() {
|
||||
let (base_url, request, handle) = one_response_server(
|
||||
"409 Conflict",
|
||||
r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#,
|
||||
);
|
||||
let expected_request = serde_json::json!({
|
||||
"expected_content_digest": ticket::ticket_content_digest(&snapshot),
|
||||
"expected_state": snapshot.meta.workflow_state.as_str(),
|
||||
"expected_closure_operation_key": snapshot.closure_operation_key,
|
||||
"operation_key": "caller-case-key",
|
||||
"action": action,
|
||||
"explanation": "Caller judgment, not work progress",
|
||||
"references": [{"kind": "ticket", "target": "T-738"}],
|
||||
});
|
||||
let mut domain_request = expected_request.clone();
|
||||
domain_request["author"] = serde_json::json!("caller-cannot-impersonate");
|
||||
let operation =
|
||||
serde_json::from_value::<ticket::TicketBackendOperation>(serde_json::json!({
|
||||
"operation": "update_case",
|
||||
"ticket": reference.id,
|
||||
"request": domain_request,
|
||||
}))
|
||||
.unwrap();
|
||||
let mut fixture_request: ticket::TicketCaseUpdate =
|
||||
serde_json::from_value(expected_request.clone()).unwrap();
|
||||
fixture_request.author = Some("authenticated-actor".into());
|
||||
let expected = backend.update_case(&reference.id, fixture_request).unwrap();
|
||||
let summary =
|
||||
serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap();
|
||||
let full_record = serde_json::to_string(&expected).unwrap();
|
||||
let (base_url, requests, handle) =
|
||||
response_sequence_server(vec![("200 OK", &summary), ("200 OK", &full_record)]);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let result = TicketBackend::complete(
|
||||
&client,
|
||||
"T-718",
|
||||
ticket::TicketCompletion {
|
||||
operation_key: "research-done".into(),
|
||||
expected_content_digest: "ticket:1".into(),
|
||||
expected_state: ticket::TicketWorkflowState::Planning,
|
||||
reason: "Research findings recorded".into(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
let request = request.recv().unwrap();
|
||||
let result = ticket::execute_ticket_backend_operation(&client, operation).unwrap();
|
||||
let ticket::TicketBackendOperationResult::Ticket(actual) = result else {
|
||||
panic!("case operation must return a full Ticket");
|
||||
};
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(actual.tags, vec![tag]);
|
||||
assert!(!actual.case_history.is_empty());
|
||||
assert_eq!(actual.closure_operation_key, expected.closure_operation_key);
|
||||
let post = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("POST /api/w/workspace-a/tickets/T-718/state-update "),
|
||||
post.starts_with(&format!(
|
||||
"POST /workspaces/workspace-a/tickets/{}/case ",
|
||||
reference.id,
|
||||
)),
|
||||
"{post}"
|
||||
);
|
||||
assert!(
|
||||
post.contains("authorization: Bearer test-token\r\n"),
|
||||
"{post}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(post.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body, expected_request);
|
||||
assert!(body.get("author").is_none());
|
||||
assert!(requests.recv().unwrap().starts_with(&format!(
|
||||
"GET /api/w/workspace-a/tickets/{}/record ",
|
||||
reference.id,
|
||||
)));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_client_uses_scoped_routes_and_preserves_cas_and_replay_input() {
|
||||
let tag = serde_json::json!({"workspace_id":"workspace-a", "id":"tag-a", "name":"Ready", "last_operation_key":"create-1", "deleted":false});
|
||||
let single = tag.to_string();
|
||||
let list = serde_json::json!([tag]).to_string();
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
("200 OK", &list),
|
||||
("200 OK", &single),
|
||||
("200 OK", &list),
|
||||
]);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let listed = client
|
||||
.list_workspace_tags(&ticket::TagListRequest {
|
||||
after: Some("tag-0".into()),
|
||||
limit: Some(7),
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(listed[0].id, "tag-a");
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("GET /api/w/workspace-a/ticket-tags?"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(
|
||||
request.contains("after=tag-0") && request.contains("limit=7"),
|
||||
"{request}"
|
||||
);
|
||||
assert!(
|
||||
request
|
||||
.to_lowercase()
|
||||
.contains("authorization: bearer test-token")
|
||||
);
|
||||
let mutation = ticket::WorkspaceTagMutation {
|
||||
operation_key: "rename-1".into(),
|
||||
action: ticket::WorkspaceTagAction::Rename {
|
||||
tag_id: "tag-a".into(),
|
||||
expected_last_operation_key: "create-1".into(),
|
||||
name: "New".into(),
|
||||
},
|
||||
};
|
||||
TicketBackend::mutate_workspace_tag(&client, mutation.clone()).unwrap();
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("POST /api/w/workspace-a/ticket-tags "),
|
||||
"{request}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body["state"], "done");
|
||||
assert_eq!(body["operation_key"], "research-done");
|
||||
assert_eq!(body["expected_content_digest"], "ticket:1");
|
||||
assert_eq!(body["expected_state"], "planning");
|
||||
assert_eq!(body["reason"], "Research findings recorded");
|
||||
assert!(body.get("merge_request_ids").is_none());
|
||||
assert_eq!(body, serde_json::json!(mutation));
|
||||
let set = ticket::TicketTagSetRequest {
|
||||
operation_key: "set-1".into(),
|
||||
expected_tag_ids: vec!["tag-old".into()],
|
||||
tag_ids: vec!["tag-a".into(), "tag-b".into()],
|
||||
};
|
||||
let receipt =
|
||||
TicketBackend::set_ticket_tags(&client, TicketIdOrSlug::from("T-740"), set.clone())
|
||||
.unwrap();
|
||||
assert_eq!(receipt[0].name, "Ready");
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("PUT /api/w/workspace-a/tickets/T-740/tags "),
|
||||
"{request}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body, serde_json::json!(set));
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classification_client_returns_backend_denials_without_local_fallback() {
|
||||
for status in ["403 Forbidden", "409 Conflict"] {
|
||||
let (base_url, requests, handle) = one_response_server(
|
||||
status,
|
||||
r#"{"error":"ticket_conflict","message":"stale or unauthorized","diagnostics":[]}"#,
|
||||
);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"token",
|
||||
)
|
||||
.unwrap();
|
||||
let result = client.set_ticket_tags(
|
||||
&TicketIdOrSlug::from("T-740"),
|
||||
&ticket::TicketTagSetRequest {
|
||||
operation_key: "retry-key".into(),
|
||||
expected_tag_ids: vec![],
|
||||
tag_ids: vec!["tag-a".into()],
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
requests
|
||||
.recv()
|
||||
.unwrap()
|
||||
.starts_with("PUT /api/w/workspace-a/tickets/T-740/tags ")
|
||||
);
|
||||
handle.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn case_request() -> ticket::TicketCaseUpdate {
|
||||
ticket::TicketCaseUpdate {
|
||||
operation_key: "caller-case-key".into(),
|
||||
expected_content_digest: "caller-content-digest".into(),
|
||||
expected_state: ticket::TicketState::Closed,
|
||||
expected_closure_operation_key: Some("last-close-key".into()),
|
||||
action: ticket::TicketCaseAction::Reopen,
|
||||
explanation: "Caller reopening judgment".into(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_update_rejection_preserves_caller_cas_without_local_fallback() {
|
||||
for (status, body, expected_error) in [
|
||||
(
|
||||
"409 Conflict",
|
||||
r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#,
|
||||
"409",
|
||||
),
|
||||
(
|
||||
"403 Forbidden",
|
||||
r#"{"error":"forbidden","message":"not authorized","diagnostics":[]}"#,
|
||||
"403",
|
||||
),
|
||||
] {
|
||||
let (base_url, requests, handle) = one_response_server(status, body);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let error = TicketBackend::update_case(&client, "T-739", case_request()).unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains(expected_error),
|
||||
"{error}; response {status}: {body}"
|
||||
);
|
||||
let request = requests.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("POST /workspaces/workspace-a/tickets/T-739/case "),
|
||||
"{request}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body["expected_content_digest"], "caller-content-digest");
|
||||
assert_eq!(body["expected_state"], "closed");
|
||||
assert_eq!(body["expected_closure_operation_key"], "last-close-key");
|
||||
assert_eq!(body["operation_key"], "caller-case-key");
|
||||
assert_eq!(body["action"], serde_json::json!({"action": "reopen"}));
|
||||
assert!(!error.to_string().contains("test-token"));
|
||||
handle.join().unwrap();
|
||||
assert!(requests.try_recv().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_case_update_with_failed_show_does_not_fabricate_full_ticket() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let reference = backend.create(NewTicket::new("Summary only")).unwrap();
|
||||
let summary =
|
||||
serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap();
|
||||
let (base_url, requests, handle) = response_sequence_server(vec![
|
||||
("200 OK", &summary),
|
||||
(
|
||||
"404 Not Found",
|
||||
r#"{"error":"ticket_not_found","message":"full record unavailable","diagnostics":[]}"#,
|
||||
),
|
||||
]);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let error = TicketBackend::update_case(&client, &reference.id, case_request()).unwrap_err();
|
||||
assert!(error.to_string().contains("404"), "{error}");
|
||||
assert!(requests.recv().unwrap().starts_with("POST /workspaces/"));
|
||||
assert!(requests.recv().unwrap().starts_with("GET /api/w/"));
|
||||
handle.join().unwrap();
|
||||
assert!(requests.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_document_uses_shared_workspace_scoped_response() {
|
||||
let body = r##"{"body_md":"# Memory\\n","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-02T00:00:00Z","bytes":10,"record_source":"workspace-sqlite"}"##;
|
||||
@@ -1414,12 +1766,12 @@ mod tests {
|
||||
assert_eq!(
|
||||
ticket_list_state_query(&TicketListQuery {
|
||||
state: TicketStateSelector::States(
|
||||
[TicketListState::Ready, TicketListState::InProgress]
|
||||
[TicketListState::Open, TicketListState::Closed]
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
}),
|
||||
"ready,inprogress"
|
||||
"open,closed"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+934
-481
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>,
|
||||
@@ -1338,7 +1341,7 @@ mod tests {
|
||||
|
||||
assert_eq!(manifest.engine.instruction, "role.ticket_worker");
|
||||
// enabled supplies ShowTicket; thread supplies TicketComment,
|
||||
// TicketWorkflowState and CompleteTicket without broad workflow access.
|
||||
// TicketCaseUpdate without broad workflow access.
|
||||
let feature = &manifest.feature;
|
||||
assert!(feature.ticket.enabled);
|
||||
assert!(feature.ticket.thread);
|
||||
@@ -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);
|
||||
|
||||
+280
-96
@@ -11,6 +11,9 @@
|
||||
//! accepted only after the initialize/initialized handshake. Shutdown closes stdin,
|
||||
//! waits for exit within a deadline, and kills/waits to reap the process if needed.
|
||||
//! Startup failures also trigger cleanup; killing on Drop is a last resort.
|
||||
//! A request shares one deadline across stdin ownership, write/flush, and matching response;
|
||||
//! synchronous result decode is outside that wait. Interrupted write/flush closes stdin.
|
||||
//! After a fully flushed frame, a missing response leaves execution unknown, not retry-safe.
|
||||
//!
|
||||
//! This module preserves Tool/resource/prompt wire values. `worker::feature::mcp`
|
||||
//! owns Yoi naming, schema acceptance, model output limits, and Feature registration.
|
||||
@@ -28,11 +31,11 @@ use secrets::SecretStore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use thiserror::Error;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStderr, ChildStdout, Command};
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::{Instant, timeout};
|
||||
use tokio::time::{Instant, timeout, timeout_at};
|
||||
|
||||
const MCP_PROTOCOL_VERSION: &str = "2025-11-25";
|
||||
const CLIENT_NAME: &str = "yoi";
|
||||
@@ -48,8 +51,11 @@ pub struct McpStdioLimits {
|
||||
pub max_diagnostic_lines: usize,
|
||||
pub max_protocol_bytes: usize,
|
||||
pub startup_timeout: Duration,
|
||||
/// One budget for stdin ownership, frame write/flush, and response wait.
|
||||
pub request_timeout: Duration,
|
||||
/// Shared budget for stdin ownership/close and graceful process exit.
|
||||
pub shutdown_timeout: Duration,
|
||||
/// Budget for each fallback wait: terminate, then forced kill/reap.
|
||||
pub kill_timeout: Duration,
|
||||
}
|
||||
|
||||
@@ -587,14 +593,39 @@ pub fn resolve_stdio_server(
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
/// A running initialized stdio MCP client.
|
||||
// The mutex orders entire frames, including replies emitted by the stdout reader.
|
||||
type SharedStdin = Arc<Mutex<Option<Box<dyn AsyncWrite + Send + Unpin>>>>;
|
||||
|
||||
/// Last boundary reached by a failed request. A flushed frame does not prove
|
||||
/// that the server executed the method; callers must not blindly retry it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum McpRequestStage {
|
||||
WaitingForStdin,
|
||||
WritingFrame,
|
||||
FlushingFrame,
|
||||
AwaitingResponse,
|
||||
}
|
||||
|
||||
impl fmt::Display for McpRequestStage {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::WaitingForStdin => "stdin ownership (method not sent)",
|
||||
Self::WritingFrame => "frame write (delivery unknown; stdin closed)",
|
||||
Self::FlushingFrame => "frame flush (delivery unknown; stdin closed)",
|
||||
Self::AwaitingResponse => "response (frame flushed; execution unknown; do not retry)",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
redactor: Redactor,
|
||||
diagnostics: Arc<Mutex<BoundedDiagnostics>>,
|
||||
list_changes: Arc<Mutex<BoundedListChanged>>,
|
||||
stdin: Arc<Mutex<Option<ChildStdin>>>,
|
||||
stdin: SharedStdin,
|
||||
child: Option<Child>,
|
||||
responses: mpsc::Receiver<ReaderEvent>,
|
||||
reader_task: JoinHandle<()>,
|
||||
@@ -606,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,
|
||||
@@ -637,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);
|
||||
@@ -658,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 = Arc::new(Mutex::new(Some(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,
|
||||
@@ -688,7 +731,18 @@ 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(
|
||||
// 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(),
|
||||
@@ -696,8 +750,13 @@ impl McpStdioClient {
|
||||
limits.clone(),
|
||||
redactor.clone(),
|
||||
list_changes.clone(),
|
||||
);
|
||||
let stderr_task = spawn_stderr_reader(stderr, diagnostics.clone(), limits.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,
|
||||
@@ -716,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(
|
||||
@@ -764,6 +842,12 @@ impl McpStdioClient {
|
||||
/// The caller is responsible for applying Yoi tool permissions before this
|
||||
/// method is reached and for bounding/serializing the untrusted result before
|
||||
/// it is exposed to model-visible tool history.
|
||||
///
|
||||
/// Timeout or cancellation after writing begins can leave execution unknown;
|
||||
/// this method never retries. Interrupted write/flush closes stdin, and the
|
||||
/// caller must shut down the process rather than reuse that connection. A
|
||||
/// fully flushed frame permits later *different* requests with distinct IDs,
|
||||
/// but is not permission to replay this call when its response is missing.
|
||||
pub async fn call_tool(
|
||||
&mut self,
|
||||
request: CallToolRequest,
|
||||
@@ -919,23 +1003,35 @@ impl McpStdioClient {
|
||||
method,
|
||||
params,
|
||||
};
|
||||
self.write_protocol(phase, &request).await?;
|
||||
let response = match timeout(
|
||||
self.limits.request_timeout,
|
||||
self.wait_for_response(id, phase),
|
||||
)
|
||||
if self.shutdown_started {
|
||||
return Err(McpClientError::new(
|
||||
&self.server_name,
|
||||
phase,
|
||||
McpErrorKind::Io("client shutdown has started".into()),
|
||||
));
|
||||
}
|
||||
let deadline = Instant::now() + self.limits.request_timeout;
|
||||
let mut stage = McpRequestStage::WaitingForStdin;
|
||||
// One budget covers ownership, write, flush, and the matching response.
|
||||
let response = match timeout_at(deadline, async {
|
||||
self.write_protocol(phase, &request, &mut stage).await?;
|
||||
stage = McpRequestStage::AwaitingResponse;
|
||||
self.wait_for_response(id, phase).await
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(result) => result?,
|
||||
Ok(Ok(response)) => response,
|
||||
Ok(Err(err)) => return Err(err.with_request_stage(stage)),
|
||||
Err(_) => {
|
||||
return Err(McpClientError::new(
|
||||
&self.server_name,
|
||||
phase,
|
||||
McpErrorKind::Timeout {
|
||||
operation: method.to_owned(),
|
||||
operation: self.redactor.redact(method),
|
||||
elapsed: self.limits.request_timeout,
|
||||
},
|
||||
)
|
||||
.with_request_stage(stage)
|
||||
.with_diagnostics(self.snapshot_diagnostics().await));
|
||||
}
|
||||
};
|
||||
@@ -1000,6 +1096,7 @@ impl McpStdioClient {
|
||||
method: &str,
|
||||
params: T,
|
||||
) -> Result<(), McpClientError> {
|
||||
let mut stage = McpRequestStage::WaitingForStdin;
|
||||
self.write_protocol(
|
||||
phase,
|
||||
&ClientNotification {
|
||||
@@ -1007,6 +1104,7 @@ impl McpStdioClient {
|
||||
method,
|
||||
params,
|
||||
},
|
||||
&mut stage,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1015,6 +1113,7 @@ impl McpStdioClient {
|
||||
&mut self,
|
||||
phase: McpPhase,
|
||||
value: &T,
|
||||
stage: &mut McpRequestStage,
|
||||
) -> Result<(), McpClientError> {
|
||||
write_json_line(
|
||||
&self.server_name,
|
||||
@@ -1023,72 +1122,74 @@ impl McpStdioClient {
|
||||
value,
|
||||
self.limits.max_protocol_bytes,
|
||||
&self.redactor,
|
||||
stage,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// 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 mut stdin = self.stdin.lock().await;
|
||||
stdin.take();
|
||||
}
|
||||
|
||||
let mut child = match self.child.take() {
|
||||
Some(child) => child,
|
||||
None => return Ok(ShutdownReport::already_finished()),
|
||||
};
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
match timeout(self.limits.shutdown_timeout, child.wait()).await {
|
||||
Ok(Ok(status)) => {
|
||||
self.reader_task.abort();
|
||||
self.stderr_task.abort();
|
||||
Ok(ShutdownReport {
|
||||
exit_status: Some(status),
|
||||
terminated: false,
|
||||
killed: false,
|
||||
let deadline = Instant::now() + self.limits.shutdown_timeout;
|
||||
// Ownership and EOF share one graceful budget. A reader reply may own
|
||||
// stdin indefinitely; its lock is never required to terminate/kill.
|
||||
let closed = timeout_at(deadline, async {
|
||||
self.stdin.lock().await.take();
|
||||
})
|
||||
.await
|
||||
.is_ok();
|
||||
|
||||
// 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().await;
|
||||
}
|
||||
|
||||
let child = self.child.as_mut().expect("checked child");
|
||||
if let Ok(Some(status)) = child.try_wait() {
|
||||
return Ok(self.finish_shutdown(status, false, false));
|
||||
}
|
||||
|
||||
match timeout_at(deadline, child.wait()).await {
|
||||
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;
|
||||
}
|
||||
match timeout(self.limits.kill_timeout, child.wait()).await {
|
||||
Ok(Ok(status)) => {
|
||||
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();
|
||||
Ok(ShutdownReport {
|
||||
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)) => Ok(self.finish_shutdown(status, terminated, false)),
|
||||
Ok(Err(err)) => Err(McpClientError::new(
|
||||
&self.server_name,
|
||||
McpPhase::Shutdown,
|
||||
@@ -1103,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(|_| {
|
||||
@@ -1123,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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1137,11 +1231,11 @@ impl McpStdioClient {
|
||||
|
||||
impl Drop for McpStdioClient {
|
||||
fn drop(&mut self) {
|
||||
if !self.shutdown_started {
|
||||
// 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();
|
||||
}
|
||||
@@ -1212,12 +1306,13 @@ pub struct McpDiagnostics {
|
||||
}
|
||||
|
||||
#[derive(Debug, Error, Clone)]
|
||||
#[error("MCP stdio server `{server_name}` failed during phase `{phase}`: {kind}")]
|
||||
#[error("MCP stdio server `{server_name}` failed during phase `{phase}`: {kind}{stage_detail}", stage_detail = request_stage.map(|stage| format!("; {stage}")).unwrap_or_default())]
|
||||
pub struct McpClientError {
|
||||
pub server_name: String,
|
||||
pub phase: McpPhase,
|
||||
pub kind: McpErrorKind,
|
||||
diagnostics: Option<McpDiagnostics>,
|
||||
request_stage: Option<McpRequestStage>,
|
||||
}
|
||||
|
||||
impl McpClientError {
|
||||
@@ -1227,6 +1322,7 @@ impl McpClientError {
|
||||
phase,
|
||||
kind,
|
||||
diagnostics: None,
|
||||
request_stage: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1240,6 +1336,15 @@ impl McpClientError {
|
||||
self
|
||||
}
|
||||
|
||||
fn with_request_stage(mut self, stage: McpRequestStage) -> Self {
|
||||
self.request_stage = Some(stage);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn request_stage(&self) -> Option<McpRequestStage> {
|
||||
self.request_stage
|
||||
}
|
||||
|
||||
pub fn diagnostics(&self) -> Option<&McpDiagnostics> {
|
||||
self.diagnostics.as_ref()
|
||||
}
|
||||
@@ -1328,7 +1433,7 @@ struct ErrorObject<'a> {
|
||||
fn spawn_stdout_reader(
|
||||
server_name: String,
|
||||
stdout: ChildStdout,
|
||||
stdin: Arc<Mutex<Option<ChildStdin>>>,
|
||||
stdin: SharedStdin,
|
||||
tx: mpsc::Sender<ReaderEvent>,
|
||||
limits: McpStdioLimits,
|
||||
redactor: Redactor,
|
||||
@@ -1385,7 +1490,7 @@ fn spawn_stdout_reader(
|
||||
|
||||
async fn handle_incoming_message(
|
||||
server_name: &str,
|
||||
stdin: &Arc<Mutex<Option<ChildStdin>>>,
|
||||
stdin: &SharedStdin,
|
||||
tx: &mpsc::Sender<ReaderEvent>,
|
||||
limits: &McpStdioLimits,
|
||||
redactor: &Redactor,
|
||||
@@ -1409,6 +1514,7 @@ async fn handle_incoming_message(
|
||||
&response,
|
||||
limits.max_protocol_bytes,
|
||||
redactor,
|
||||
&mut McpRequestStage::WaitingForStdin,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1501,10 +1607,11 @@ fn spawn_stderr_reader(
|
||||
async fn write_json_line<T: Serialize>(
|
||||
server_name: &str,
|
||||
phase: McpPhase,
|
||||
stdin: &Arc<Mutex<Option<ChildStdin>>>,
|
||||
stdin: &SharedStdin,
|
||||
value: &T,
|
||||
max_protocol_bytes: usize,
|
||||
redactor: &Redactor,
|
||||
stage: &mut McpRequestStage,
|
||||
) -> Result<(), McpClientError> {
|
||||
let mut bytes = serde_json::to_vec(value).map_err(|err| {
|
||||
McpClientError::new(
|
||||
@@ -1524,27 +1631,34 @@ async fn write_json_line<T: Serialize>(
|
||||
}
|
||||
bytes.push(b'\n');
|
||||
let mut guard = stdin.lock().await;
|
||||
let Some(stdin) = guard.as_mut() else {
|
||||
let Some(mut writer) = guard.take() else {
|
||||
return Err(McpClientError::new(
|
||||
server_name,
|
||||
phase,
|
||||
McpErrorKind::Io("child stdin is closed".into()),
|
||||
));
|
||||
};
|
||||
stdin.write_all(&bytes).await.map_err(|err| {
|
||||
// Keep ownership out of the slot across both awaits. Timeout/cancel or
|
||||
// I/O error drops the writer, closing the connection instead of allowing
|
||||
// the next frame to append to an unverified partial JSON line.
|
||||
*stage = McpRequestStage::WritingFrame;
|
||||
writer.write_all(&bytes).await.map_err(|err| {
|
||||
McpClientError::new(
|
||||
server_name,
|
||||
phase,
|
||||
McpErrorKind::Io(redactor.redact(&err.to_string())),
|
||||
)
|
||||
})?;
|
||||
stdin.flush().await.map_err(|err| {
|
||||
*stage = McpRequestStage::FlushingFrame;
|
||||
writer.flush().await.map_err(|err| {
|
||||
McpClientError::new(
|
||||
server_name,
|
||||
phase,
|
||||
McpErrorKind::Io(redactor.redact(&err.to_string())),
|
||||
)
|
||||
})
|
||||
})?;
|
||||
*guard = Some(writer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read_protocol_line<R: AsyncRead + Unpin>(
|
||||
@@ -1701,3 +1815,73 @@ fn send_terminate(child: &mut Child) -> Result<(), ()> {
|
||||
fn send_terminate(child: &mut Child) -> Result<(), ()> {
|
||||
child.start_kill().map_err(|_| ())
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
//! Module-boundary fixtures exercise the same writer/request path as ChildStdin.
|
||||
use super::*;
|
||||
use std::future::{Future, poll_fn};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Stall {
|
||||
PartialWrite,
|
||||
Flush,
|
||||
Response,
|
||||
}
|
||||
|
||||
struct ControlledWriter {
|
||||
stall: Stall,
|
||||
bytes: Arc<StdMutex<Vec<u8>>>,
|
||||
reached: Option<mpsc::UnboundedSender<()>>,
|
||||
dropped: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl ControlledWriter {
|
||||
fn signal(&mut self) {
|
||||
if let Some(tx) = self.reached.take() {
|
||||
tx.send(()).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AsyncWrite for ControlledWriter {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
_: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
if matches!(self.stall, Stall::PartialWrite) {
|
||||
if self.bytes.lock().unwrap().is_empty() {
|
||||
self.bytes.lock().unwrap().extend_from_slice(&buf[..8]);
|
||||
return Poll::Ready(Ok(8));
|
||||
}
|
||||
self.signal();
|
||||
return Poll::Pending;
|
||||
}
|
||||
self.bytes.lock().unwrap().extend_from_slice(buf);
|
||||
Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
|
||||
fn poll_flush(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
self.signal();
|
||||
if matches!(self.stall, Stall::Flush) {
|
||||
Poll::Pending
|
||||
} else {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ControlledWriter {
|
||||
fn drop(&mut self) {
|
||||
self.dropped.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture_client(stdin: SharedStdin) -> (McpStdioClient, mpsc::Sender<ReaderEvent>) {
|
||||
let (tx, responses) = mpsc::channel(16);
|
||||
let redactor = Redactor::new(vec!["fixture-secret".into()]);
|
||||
let client = McpStdioClient {
|
||||
server_name: "deadline-fixture".into(),
|
||||
limits: McpStdioLimits {
|
||||
request_timeout: Duration::from_millis(100),
|
||||
..Default::default()
|
||||
},
|
||||
diagnostics: Arc::new(Mutex::new(BoundedDiagnostics::new(
|
||||
"deadline-fixture".into(),
|
||||
2,
|
||||
redactor.clone(),
|
||||
))),
|
||||
redactor,
|
||||
list_changes: Arc::new(Mutex::new(BoundedListChanged::new(
|
||||
"deadline-fixture".into(),
|
||||
))),
|
||||
stdin,
|
||||
child: None,
|
||||
responses,
|
||||
reader_task: tokio::spawn(std::future::pending()),
|
||||
stderr_task: tokio::spawn(std::future::pending()),
|
||||
next_id: 1,
|
||||
initialized: None,
|
||||
shutdown_started: false,
|
||||
};
|
||||
(client, tx)
|
||||
}
|
||||
|
||||
async fn assert_pending<F: Future>(mut future: Pin<&mut F>) {
|
||||
poll_fn(|cx| match future.as_mut().poll(cx) {
|
||||
Poll::Pending => Poll::Ready(()),
|
||||
Poll::Ready(_) => panic!("fixture boundary unexpectedly completed"),
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdin_ownership_timeout_and_cancel_do_not_send_a_method() {
|
||||
for cancel in [false, true] {
|
||||
let bytes = Arc::new(StdMutex::new(Vec::new()));
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let stdin: SharedStdin = Arc::new(Mutex::new(Some(Box::new(ControlledWriter {
|
||||
stall: Stall::Response,
|
||||
bytes: bytes.clone(),
|
||||
reached: None,
|
||||
dropped: dropped.clone(),
|
||||
}))));
|
||||
let guard = stdin.lock().await;
|
||||
let (mut client, _tx) = fixture_client(stdin.clone());
|
||||
let mut request =
|
||||
Box::pin(client.request::<Value>(McpPhase::Running, "tools/call", json!({})));
|
||||
assert_pending(request.as_mut()).await;
|
||||
if !cancel {
|
||||
let err = timeout(Duration::from_secs(1), &mut request)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert!(matches!(err.kind, McpErrorKind::Timeout { .. }));
|
||||
assert_eq!(err.request_stage(), Some(McpRequestStage::WaitingForStdin));
|
||||
assert!(err.to_string().contains("method not sent"));
|
||||
}
|
||||
drop(request);
|
||||
assert!(bytes.lock().unwrap().is_empty());
|
||||
assert!(!dropped.load(Ordering::SeqCst));
|
||||
assert!(
|
||||
guard.is_some(),
|
||||
"cancel before ownership must preserve the stream"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn interrupted_write_or_flush_closes_stdin_before_the_next_frame() {
|
||||
for (stall, stage) in [
|
||||
(Stall::PartialWrite, McpRequestStage::WritingFrame),
|
||||
(Stall::Flush, McpRequestStage::FlushingFrame),
|
||||
] {
|
||||
for cancel in [false, true] {
|
||||
let bytes = Arc::new(StdMutex::new(Vec::new()));
|
||||
let dropped = Arc::new(AtomicBool::new(false));
|
||||
let (reached, mut rx) = mpsc::unbounded_channel();
|
||||
let stdin: SharedStdin = Arc::new(Mutex::new(Some(Box::new(ControlledWriter {
|
||||
stall,
|
||||
bytes: bytes.clone(),
|
||||
reached: Some(reached),
|
||||
dropped: dropped.clone(),
|
||||
}))));
|
||||
let (mut client, _tx) = fixture_client(stdin.clone());
|
||||
let mut request = Box::pin(client.request::<Value>(
|
||||
McpPhase::Running,
|
||||
"tools/call",
|
||||
json!({"secret": "fixture-secret"}),
|
||||
));
|
||||
// Poll all the way to the controlled Pending, then observe its channel.
|
||||
assert_pending(request.as_mut()).await;
|
||||
timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
if !cancel {
|
||||
let err = timeout(Duration::from_secs(1), &mut request)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert!(matches!(err.kind, McpErrorKind::Timeout { .. }));
|
||||
assert_eq!(err.request_stage(), Some(stage));
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("delivery unknown; stdin closed"));
|
||||
assert!(!message.contains("fixture-secret"));
|
||||
}
|
||||
drop(request);
|
||||
assert!(dropped.load(Ordering::SeqCst));
|
||||
assert!(stdin.lock().await.is_none());
|
||||
let before = bytes.lock().unwrap().clone();
|
||||
if matches!(stall, Stall::PartialWrite) {
|
||||
assert!(!before.contains(&b'\n'));
|
||||
} else {
|
||||
assert_eq!(before.last(), Some(&b'\n'));
|
||||
}
|
||||
let err = client
|
||||
.request::<Value>(McpPhase::Running, "next-method", json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().contains("stdin is closed"));
|
||||
assert_eq!(
|
||||
*bytes.lock().unwrap(),
|
||||
before,
|
||||
"no next frame can append after interruption"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flushed_call_timeout_or_cancel_is_not_replayed_and_late_response_is_not_mixed() {
|
||||
for cancel in [false, true] {
|
||||
let bytes = Arc::new(StdMutex::new(Vec::new()));
|
||||
let (reached, mut rx) = mpsc::unbounded_channel();
|
||||
let stdin: SharedStdin = Arc::new(Mutex::new(Some(Box::new(ControlledWriter {
|
||||
stall: Stall::Response,
|
||||
bytes: bytes.clone(),
|
||||
reached: Some(reached),
|
||||
dropped: Arc::new(AtomicBool::new(false)),
|
||||
}))));
|
||||
let (mut client, tx) = fixture_client(stdin);
|
||||
let mut request =
|
||||
Box::pin(client.request::<Value>(McpPhase::Running, "tools/call", json!({})));
|
||||
assert_pending(request.as_mut()).await;
|
||||
timeout(Duration::from_secs(1), rx.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
if !cancel {
|
||||
let err = timeout(Duration::from_secs(1), &mut request)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap_err();
|
||||
assert_eq!(err.request_stage(), Some(McpRequestStage::AwaitingResponse));
|
||||
assert!(err.to_string().contains("execution unknown; do not retry"));
|
||||
}
|
||||
drop(request);
|
||||
// Reuse is safe only because the first frame completed and IDs are
|
||||
// matched. This is a different method, not a replay of the unknown call.
|
||||
tx.send(ReaderEvent::Response(ServerResponse {
|
||||
id: 1,
|
||||
result: Some(json!({"late": true})),
|
||||
error: None,
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
tx.send(ReaderEvent::Response(ServerResponse {
|
||||
id: 2,
|
||||
result: Some(json!({"next": true})),
|
||||
error: None,
|
||||
}))
|
||||
.await
|
||||
.unwrap();
|
||||
let result = client
|
||||
.request::<Value>(McpPhase::Running, "next-method", json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result, json!({"next": true}));
|
||||
let captured = bytes.lock().unwrap().clone();
|
||||
let frames: Vec<Value> = captured
|
||||
.split(|byte| *byte == b'\n')
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(|line| serde_json::from_slice(line).unwrap())
|
||||
.collect();
|
||||
assert_eq!(frames.len(), 2);
|
||||
assert_eq!(frames[0]["method"], "tools/call");
|
||||
assert_eq!(frames[1]["method"], "next-method");
|
||||
}
|
||||
}
|
||||
|
||||
// Observes actual OS-pipe backpressure, not a guessed delay or just a mock
|
||||
// Pending. It forwards every operation to ChildStdin without changing framing.
|
||||
struct PendingObserver<W> {
|
||||
writer: W,
|
||||
accepted: usize,
|
||||
reached: Option<mpsc::UnboundedSender<()>>,
|
||||
}
|
||||
impl<W: AsyncWrite + Unpin> AsyncWrite for PendingObserver<W> {
|
||||
fn poll_write(
|
||||
mut self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<std::io::Result<usize>> {
|
||||
let result = Pin::new(&mut self.writer).poll_write(cx, buf);
|
||||
if let Poll::Ready(Ok(count)) = &result {
|
||||
self.accepted += count;
|
||||
}
|
||||
if result.is_pending() && self.accepted > 1024 {
|
||||
if let Some(tx) = self.reached.take() {
|
||||
tx.send(()).unwrap();
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut self.writer).poll_flush(cx)
|
||||
}
|
||||
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
|
||||
Pin::new(&mut self.writer).poll_shutdown(cx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn blocked_reader_reply_does_not_prevent_shutdown_process_reaping() {
|
||||
// A local stdio fixture: initialize, consume initialized, emit an unsupported
|
||||
// server request whose echoed ID exceeds pipe capacity, then never read.
|
||||
// The shell has no descendants and ignores TERM to also prove kill fallback.
|
||||
let init = json!({"jsonrpc":"2.0", "id":1, "result": {"protocolVersion": MCP_PROTOCOL_VERSION, "capabilities":{}, "serverInfo":{"name":"fixture", "version":"1"}}});
|
||||
let server_request =
|
||||
json!({"jsonrpc":"2.0", "id":"x".repeat(256 * 1024), "method":"sampling/createMessage"});
|
||||
// Avoid argv limits: generate the large protocol ID in the shell itself.
|
||||
let script = format!(
|
||||
"trap '' TERM; read -r init; printf '%s\\n' '{init}'; read -r initialized; printf '%s' '{{\"jsonrpc\":\"2.0\",\"method\":\"sampling/createMessage\",\"id\":\"'; i=0; while [ $i -lt 4096 ]; do printf '%s' '{}'; i=$((i+1)); done; printf '%s\\n' '\"}}'; while :; do :; done",
|
||||
"x".repeat(64)
|
||||
);
|
||||
assert!(server_request.to_string().len() < McpStdioLimits::default().max_stdout_line_bytes);
|
||||
let limits = McpStdioLimits {
|
||||
shutdown_timeout: Duration::from_millis(50),
|
||||
kill_timeout: Duration::from_millis(100),
|
||||
..Default::default()
|
||||
};
|
||||
let mut client = McpStdioClient::spawn(
|
||||
McpStdioServerSpec::new("reader-blocked", "/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(script),
|
||||
limits,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let pid = client.child.as_ref().unwrap().id().unwrap();
|
||||
let (reached, mut rx) = mpsc::unbounded_channel();
|
||||
{
|
||||
let mut guard = client.stdin.lock().await;
|
||||
let writer = guard.take().unwrap();
|
||||
*guard = Some(Box::new(PendingObserver {
|
||||
writer,
|
||||
accepted: 0,
|
||||
reached: Some(reached),
|
||||
}));
|
||||
}
|
||||
client.initialize().await.unwrap();
|
||||
timeout(Duration::from_secs(3), rx.recv())
|
||||
.await
|
||||
.expect("reader reply reached OS pipe Pending")
|
||||
.unwrap();
|
||||
assert!(
|
||||
client.stdin.try_lock().is_err(),
|
||||
"reader owns stdin while its reply is blocked"
|
||||
);
|
||||
let report = timeout(Duration::from_secs(1), client.shutdown())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(report.terminated && report.killed);
|
||||
assert!(report.exit_status.is_some());
|
||||
assert!(client.child.is_none());
|
||||
// Tokio wait already reaped the child; an additional wait cannot find it.
|
||||
let mut status = 0;
|
||||
assert_eq!(
|
||||
unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) },
|
||||
-1
|
||||
);
|
||||
assert_eq!(
|
||||
std::io::Error::last_os_error().raw_os_error(),
|
||||
Some(libc::ECHILD)
|
||||
);
|
||||
}
|
||||
+18
-1
@@ -6,8 +6,11 @@ use std::time::Duration;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn main() {
|
||||
let mode = env::var("YOI_MCP_MOCK_MODE").unwrap_or_else(|_| "success".to_string());
|
||||
let mode = env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| env::var("YOI_MCP_MOCK_MODE").unwrap_or_else(|_| "success".to_string()));
|
||||
match mode.as_str() {
|
||||
"stdin-unread" => stdin_unread(),
|
||||
"success" => success(),
|
||||
"tools" => tools_list(),
|
||||
"tools-call-normal" => tools_call_normal(),
|
||||
@@ -22,6 +25,20 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
fn stdin_unread() {
|
||||
let init = read_json();
|
||||
assert_eq!(init["method"], "initialize");
|
||||
write_json(json!({"jsonrpc": "2.0", "id": init["id"], "result": initialize_result()}));
|
||||
assert_eq!(read_json()["method"], "notifications/initialized");
|
||||
// Protocol barrier: the host only writes its oversized call after this ack.
|
||||
let ready = read_json();
|
||||
assert_eq!(ready["method"], "fixture/ready");
|
||||
write_json(json!({"jsonrpc": "2.0", "id": ready["id"], "result": {"ready": true}}));
|
||||
loop {
|
||||
thread::park();
|
||||
}
|
||||
}
|
||||
|
||||
fn success() {
|
||||
let init = read_json();
|
||||
assert_eq!(init["method"], "initialize");
|
||||
|
||||
@@ -278,3 +278,48 @@ async fn sampling_requests_fail_closed_and_are_not_advertised() {
|
||||
let shutdown = client.shutdown().await.expect("shutdown succeeds");
|
||||
assert!(shutdown.exit_status.is_some_and(|status| status.success()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unread_stdin_request_times_out_during_write_and_closes_before_reuse() {
|
||||
let limits = McpStdioLimits {
|
||||
request_timeout: Duration::from_millis(100),
|
||||
..tight_limits()
|
||||
};
|
||||
let spec = McpStdioServerSpec::new("unread-stdin", env!("CARGO_BIN_EXE_mcp-stdio-mock-server"))
|
||||
.arg("stdin-unread");
|
||||
let mut client = McpStdioClient::connect(spec, limits).await.unwrap();
|
||||
let ready: serde_json::Value = client
|
||||
.request(McpPhase::Running, "fixture/ready", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ready["ready"], true);
|
||||
// Far beyond OS pipe capacity, but inside the existing protocol bound.
|
||||
let call = CallToolRequest::new(
|
||||
"write-blocked",
|
||||
serde_json::json!({"payload": "x".repeat(768 * 1024)}),
|
||||
);
|
||||
let err = tokio::time::timeout(Duration::from_secs(1), client.call_tool(call))
|
||||
.await
|
||||
.expect("the whole request, not only response wait, is bounded")
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err.kind, McpErrorKind::Timeout { ref operation, .. } if operation == "tools/call")
|
||||
);
|
||||
assert_eq!(
|
||||
err.request_stage(),
|
||||
Some(mcp::stdio::McpRequestStage::WritingFrame)
|
||||
);
|
||||
// The fixture barrier and reported stage establish actual write backpressure;
|
||||
// the separate controlled fixture also verifies the exact partial bytes.
|
||||
let next = client
|
||||
.request::<serde_json::Value>(McpPhase::Running, "fixture/next", serde_json::json!({}))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(next.to_string().contains("stdin is closed"));
|
||||
let report = tokio::time::timeout(Duration::from_secs(1), client.shutdown())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(report.terminated || report.killed);
|
||||
assert!(report.exit_status.is_some());
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
@@ -610,7 +605,9 @@ pub struct CompleteMergeRequest {
|
||||
pub now: DateTime<Utc>,
|
||||
}
|
||||
/// Historical MR-owned Ticket completion payload, retained for reading existing events.
|
||||
/// Canonical Ticket completion is owned by the Ticket domain.
|
||||
/// Current close/reopen/correction judgments belong to the Ticket domain and require no
|
||||
/// universal MR evidence gate. This archived payload is neither a current case operation nor
|
||||
/// authority to infer closure, self-reported handling, work release, or Worker termination.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
|
||||
pub struct TicketCompletionEvent {
|
||||
pub operation_id: String,
|
||||
@@ -691,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 [
|
||||
@@ -754,6 +762,7 @@ impl MergeRequestStore {
|
||||
None,
|
||||
)?
|
||||
}
|
||||
retain_evidence(&t)?;
|
||||
t.commit()?;
|
||||
drop(c);
|
||||
self.get_by_id(&i.auth.workspace_id, &merge_request_id)
|
||||
@@ -812,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())
|
||||
{
|
||||
@@ -925,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
|
||||
@@ -1021,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()
|
||||
@@ -1138,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(),
|
||||
@@ -1171,18 +1176,9 @@ impl MergeRequestStore {
|
||||
"approval predates the current Ticket digest; fresh review is required".into(),
|
||||
));
|
||||
}
|
||||
let state: Option<String> = connection
|
||||
.query_row(
|
||||
"SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![mr.workspace_id, i.ticket_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if state.as_deref() != Some("inprogress") {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket must be inprogress".into(),
|
||||
));
|
||||
}
|
||||
// Case openness is not an integration phase. Exact source approval,
|
||||
// current Ticket content and current assignment fencing above remain
|
||||
// the independent MR integration guards.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1224,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],
|
||||
@@ -1254,18 +1250,9 @@ impl MergeRequestStore {
|
||||
"approval predates the current Ticket digest; fresh review is required".into(),
|
||||
));
|
||||
}
|
||||
let state: Option<String> = transaction
|
||||
.query_row(
|
||||
"SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
|
||||
params![mr.workspace_id, i.ticket_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if state.as_deref() != Some("inprogress") {
|
||||
return Err(MergeRequestError::Conflict(
|
||||
"Ticket must be inprogress".into(),
|
||||
));
|
||||
}
|
||||
// Case openness is not an integration phase. Exact source approval,
|
||||
// current Ticket content and active assignment fencing above remain
|
||||
// the independent MR integration guards.
|
||||
let issued_grants = {
|
||||
let mut statement = transaction.prepare(
|
||||
"SELECT request_event_id,subject_ref,capability_token
|
||||
@@ -1552,7 +1539,7 @@ impl MergeRequestStore {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn completion_auth(
|
||||
fn current_assignment_auth(
|
||||
&self,
|
||||
a: &MergeRequestAuth,
|
||||
t: &str,
|
||||
@@ -1751,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")
|
||||
);
|
||||
}
|
||||
+171
-159
@@ -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"];
|
||||
|
||||
fn root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[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"));
|
||||
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 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"));
|
||||
assert_eq!(
|
||||
cargo["dependencies"]["yoi-plugin-pdk"]["path"].as_str(),
|
||||
Some("../../../../crates/plugin-pdk")
|
||||
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")
|
||||
);
|
||||
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"));
|
||||
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 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")
|
||||
fn pure_starter_builds_real_component_without_imports() {
|
||||
build_component(
|
||||
"rust-component-tool",
|
||||
"yoi_rust_component_tool_template",
|
||||
&[],
|
||||
);
|
||||
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\""));
|
||||
}
|
||||
|
||||
#[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 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",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_template_cargo_checks_for_wasm_target() {
|
||||
cargo_check_template("rust-component-tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_service_template_cargo_checks_for_wasm_target() {
|
||||
cargo_check_template("rust-component-instance");
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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| {
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user