Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96592b1a24 | ||
|
|
d70dee77a9 | ||
|
|
1402240ef0 | ||
|
|
decd1311cb | ||
|
|
c4622e9e3a | ||
|
|
360387c986 | ||
|
|
bc0342b03e | ||
|
|
10ebac142e | ||
|
|
d6bef5d1c7 | ||
|
|
9d8e0114f5 | ||
|
|
93070a3a93 | ||
|
|
d1b94625b1 | ||
|
|
fbbea1bf91 | ||
|
|
a0f8ee51a4 | ||
|
|
ffc1933f06 | ||
|
|
b6960878a6 | ||
|
|
fef3b6f4a0 | ||
|
|
c0a73c12ec | ||
|
|
f40714efc2 | ||
|
|
07ed908ae6 | ||
|
|
cfe772ea7d | ||
|
|
c0fe20e8a2 | ||
|
|
df22526a0d | ||
|
|
1d06d4b08a | ||
|
|
4e7a314a00 | ||
|
|
016dbd7cb1 | ||
|
|
7210d3c202 | ||
|
|
572204b49a | ||
|
|
86dd67a24c | ||
|
|
beeba1fdfc | ||
|
|
41b7b289d0 | ||
|
|
24237249d1 | ||
|
|
e448073b98 | ||
|
|
c08152d069 | ||
|
|
3995561220 | ||
|
|
aea51caeb4 | ||
|
|
3eca380bd8 | ||
|
|
8a3e06bc81 | ||
|
|
c4274c42cb | ||
|
|
a61ad15767 | ||
|
|
7f1e374fd7 | ||
|
|
e3f5445a02 | ||
|
|
d2cb50d081 | ||
|
|
6c609808c9 | ||
|
|
2d4c7b383a | ||
|
|
c21ed7dff2 | ||
|
|
448e392a0e | ||
|
|
d97c40d6af | ||
|
|
2d512b6be6 | ||
|
|
f061a95b48 | ||
|
|
eefdef1bef | ||
|
|
7675f81999 | ||
|
|
8fb592071f | ||
|
|
2528312142 | ||
|
|
08d7965ea8 | ||
|
|
e0badad91f | ||
|
|
f0a91ce2d8 | ||
|
|
24cab83f48 | ||
|
|
33a2b5d702 | ||
|
|
7f807004ad | ||
|
|
5564425488 | ||
|
|
4a89c04732 | ||
|
|
ec5a403ec6 | ||
|
|
f6ce1df766 | ||
|
|
9d7ddcc04a | ||
|
|
3df611636b | ||
|
|
d0999326bd | ||
|
|
6fbc65476c | ||
|
|
fcc7d79d80 | ||
|
|
18fd6a1f5e | ||
|
|
a072562034 | ||
|
|
2b4a2bc688 | ||
|
|
3344d9f8b2 | ||
|
|
fae36d220d | ||
|
|
7b6a84a550 | ||
|
|
f29c343879 | ||
|
|
f5e9f49a13 | ||
|
|
73a35599d2 | ||
|
|
5080d7860e | ||
|
|
7fb1d4056c | ||
|
|
243a081874 | ||
|
|
04924cf796 | ||
|
|
8f0917b8bc | ||
|
|
d4ad46127a | ||
|
|
fba5ecf54c | ||
|
|
e035df9e7b | ||
|
|
3baf0b6358 | ||
|
|
4de04e42b5 | ||
|
|
ebec98a14c |
@@ -1,21 +1,19 @@
|
|||||||
すでにシステムのドッグフーディングに成功しているが、一旦安定した旧バージョンで、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceを作るObjectiveを進めている。
|
すでにシステムのドッグフーディングに成功しており、ブラウザ/TUI Client/backend/runtimeの分離とチームスペースとしてのworkspaceの実装を進めている。
|
||||||
|
|
||||||
## このシステムに置ける設計要旨
|
## このシステムに置ける設計要旨
|
||||||
|
|
||||||
- プロンプトはすべて resources/promptsに集約している。管理効率の向上と同時に、ユーザーがオーバーライドする形式でもある。
|
- プロンプトはすべて`resources/prompts`に集約している。管理効率の向上のためであると同時に、ユーザーがオーバーライドする形式でもある。
|
||||||
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
|
||||||
|
|
||||||
### LLM コンテキストの加工原則
|
### LLM コンテキストの加工原則
|
||||||
|
|
||||||
LLM に投げる context への割り込みは、大きく2種類に分かれる。**前者は許されるが、後者は禁止**。
|
LLM に投げる context はappend-onlyが基本であり、またその永続化形式からAPIコールの形式を純粋に再現可能である必要が有る。
|
||||||
|
|
||||||
Workerの状態から純粋に再現可能で、且つ揮発性の無い操作であることが望ましい。(pruning、tool result の content 切り詰め、prompt cache anchor の付与等)。
|
一時的にメッセージを差し込む等の、揮発性の有るコンテキストの改変や、過去のメッセージを改ざんすることは基本的に禁止されている。
|
||||||
原則として、コンテキストは積み重ねるものであり、一時的にメッセージを差し込むことや、過去のメッセージを改ざんすることはKVキャッシュのヒット率を下げる。
|
これを行うと、 LLM はそのコンテキストに基づいて生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
||||||
|
|
||||||
**禁止**: ターンを跨ぐことができない情報に基づいて、history に記録せずに context だけにコンテンツを差し込むこと。これをやると LLM はそれに反応して生成を行う一方、次以降のターンでhistoryに残らないため、「自分がなぜその発言/tool call をしたか」の根拠が消えるうえ、prompt cache のヒット率も低下させることになる。
|
過去のコンテキストの圧縮は、キャッシュ破壊とトークン消費のトレードオフであり、必要であれば行っている。
|
||||||
|
しかし、キャッシュを破壊するタイミングと頻度は正確にコントロールされる必要があり、実際のセッションデータの解析に基づいて慎重に設計されるべきである。
|
||||||
新しい input を context に乗せたいなら、必ず先に `worker.history` に append して commit すること。`history.json` への永続化はそこから自動的についてくる。Notify / WorkerEvent / typed `SystemItem` reminder はこの原則で扱う。
|
|
||||||
また、キャッシュを破壊するタイミングは正確にコントロールされる必要があり、キャッシュ破壊とトークン消費のトレードオフに基づいて慎重に設計されるべきである。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Generated
+35
-654
@@ -2,15 +2,6 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "addr2line"
|
|
||||||
version = "0.26.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a"
|
|
||||||
dependencies = [
|
|
||||||
"gimli",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "adler2"
|
name = "adler2"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
@@ -177,12 +168,6 @@ version = "1.0.102"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "arbitrary"
|
|
||||||
version = "1.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arc-swap"
|
name = "arc-swap"
|
||||||
version = "1.9.1"
|
version = "1.9.1"
|
||||||
@@ -476,9 +461,6 @@ name = "bumpalo"
|
|||||||
version = "3.20.2"
|
version = "3.20.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
|
||||||
dependencies = [
|
|
||||||
"allocator-api2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytecount"
|
name = "bytecount"
|
||||||
@@ -662,15 +644,6 @@ dependencies = [
|
|||||||
"cc",
|
"cc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cobs"
|
|
||||||
version = "0.3.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
|
||||||
dependencies = [
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
@@ -765,15 +738,6 @@ version = "0.8.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cpp_demangle"
|
|
||||||
version = "0.4.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -792,148 +756,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cranelift-assembler-x64"
|
|
||||||
version = "0.132.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0bc293b86236abcc45f2f72e2d18e2bd636f2a08b75eb286bae31e71e1430c91"
|
|
||||||
dependencies = [
|
|
||||||
"cranelift-assembler-x64-meta",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cranelift-assembler-x64-meta"
|
|
||||||
version = "0.132.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b954c826eddaf1b001402cb8aecf1764c6f6d637ba69fb9e3311f1ebac965be6"
|
|
||||||
dependencies = [
|
|
||||||
"cranelift-srcgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cranelift-bforest"
|
|
||||||
version = "0.132.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4053fa2575ef4a5c35d2708533df2200400ae979226cea9cc92a578b811bd4e7"
|
|
||||||
dependencies = [
|
|
||||||
"cranelift-entity",
|
|
||||||
"wasmtime-internal-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cranelift-bitset"
|
|
||||||
version = "0.132.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d216663191014aa63e1d2cffd058e609eaf207646d40b739d88250f65b2c4f69"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"wasmtime-internal-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cranelift-codegen"
|
|
||||||
version = "0.132.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9a5e7e7aad6a425a51da1ad7ab9e5d280ea97eb7c7c4545fafb567915a75aadb"
|
|
||||||
dependencies = [
|
|
||||||
"bumpalo",
|
|
||||||
"cranelift-assembler-x64",
|
|
||||||
"cranelift-bforest",
|
|
||||||
"cranelift-bitset",
|
|
||||||
"cranelift-codegen-meta",
|
|
||||||
"cranelift-codegen-shared",
|
|
||||||
"cranelift-control",
|
|
||||||
"cranelift-entity",
|
|
||||||
"cranelift-isle",
|
|
||||||
"gimli",
|
|
||||||
"hashbrown 0.17.1",
|
|
||||||
"libm",
|
|
||||||
"log",
|
|
||||||
"pulley-interpreter",
|
|
||||||
"regalloc2",
|
|
||||||
"rustc-hash",
|
|
||||||
"serde",
|
|
||||||
"smallvec",
|
|
||||||
"target-lexicon",
|
|
||||||
"wasmtime-internal-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cranelift-codegen-meta"
|
|
||||||
version = "0.132.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c421d80a9a85f806cb02a2983b5b5368a335c319795b1f1b4b771a24479af5b0"
|
|
||||||
dependencies = [
|
|
||||||
"cranelift-assembler-x64-meta",
|
|
||||||
"cranelift-codegen-shared",
|
|
||||||
"cranelift-srcgen",
|
|
||||||
"heck",
|
|
||||||
"pulley-interpreter",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cranelift-codegen-shared"
|
|
||||||
version = "0.132.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "78fdb83ab012d0ee6a44ced7ca8788a444f17cf821c62f95d6ef87c9f0262518"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cranelift-control"
|
|
||||||
version = "0.132.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1b75adc6eb7bb4ac6365106afb6cac4f12fe1ddfa02ddc9fd7015ca1469b471b"
|
|
||||||
dependencies = [
|
|
||||||
"arbitrary",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cranelift-entity"
|
|
||||||
version = "0.132.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "668e56db75a54816cbdd7c7b7bfc558b08bf7b2cda9d0846491517e92f3b393b"
|
|
||||||
dependencies = [
|
|
||||||
"cranelift-bitset",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"wasmtime-internal-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cranelift-frontend"
|
|
||||||
version = "0.132.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c63892dc1cc3ae48680183fa66997f60ffe7f1e200c8d390f8ee66edff4aef5a"
|
|
||||||
dependencies = [
|
|
||||||
"cranelift-codegen",
|
|
||||||
"log",
|
|
||||||
"smallvec",
|
|
||||||
"target-lexicon",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cranelift-isle"
|
|
||||||
version = "0.132.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "94eaf429c32a12715429c7c6ddfdd43c170f4cdd7e97bfa507bd68a652091087"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cranelift-native"
|
|
||||||
version = "0.132.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cd77674904ae9be11c1e1efdba54788b59f3d6658d747b97534bfbba2909aacc"
|
|
||||||
dependencies = [
|
|
||||||
"cranelift-codegen",
|
|
||||||
"libc",
|
|
||||||
"target-lexicon",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cranelift-srcgen"
|
|
||||||
version = "0.132.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cba7c0ff5941842c36653da155580ce41e675c204a67ac1b4e1c478a9347bbb7"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc32fast"
|
name = "crc32fast"
|
||||||
version = "1.5.0"
|
version = "1.5.0"
|
||||||
@@ -1374,18 +1196,6 @@ dependencies = [
|
|||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "embedded-io"
|
|
||||||
version = "0.4.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "embedded-io"
|
|
||||||
version = "0.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "encoding_rs"
|
name = "encoding_rs"
|
||||||
version = "0.8.35"
|
version = "0.8.35"
|
||||||
@@ -1788,18 +1598,6 @@ dependencies = [
|
|||||||
"polyval",
|
"polyval",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gimli"
|
|
||||||
version = "0.33.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c"
|
|
||||||
dependencies = [
|
|
||||||
"fnv",
|
|
||||||
"hashbrown 0.16.1",
|
|
||||||
"indexmap",
|
|
||||||
"stable_deref_trait",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glob"
|
name = "glob"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -1922,11 +1720,6 @@ name = "hashbrown"
|
|||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
dependencies = [
|
|
||||||
"foldhash 0.2.0",
|
|
||||||
"serde",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
@@ -2614,15 +2407,6 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mach2"
|
|
||||||
version = "0.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "manifest"
|
name = "manifest"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -2713,15 +2497,6 @@ version = "2.8.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "memfd"
|
|
||||||
version = "0.6.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227"
|
|
||||||
dependencies = [
|
|
||||||
"rustix 1.1.4",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "memmap2"
|
name = "memmap2"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
@@ -3003,18 +2778,6 @@ version = "0.2.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
|
checksum = "6aa2c4e539b869820a2b82e1aef6ff40aa85e65decdd5185e83fb4b1249cd00f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "object"
|
|
||||||
version = "0.39.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b"
|
|
||||||
dependencies = [
|
|
||||||
"crc32fast",
|
|
||||||
"hashbrown 0.17.1",
|
|
||||||
"indexmap",
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "oid-registry"
|
name = "oid-registry"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -3410,18 +3173,6 @@ version = "1.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "postcard"
|
|
||||||
version = "1.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
|
||||||
dependencies = [
|
|
||||||
"cobs",
|
|
||||||
"embedded-io 0.4.0",
|
|
||||||
"embedded-io 0.6.1",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "postscript"
|
name = "postscript"
|
||||||
version = "0.14.1"
|
version = "0.14.1"
|
||||||
@@ -3524,29 +3275,6 @@ dependencies = [
|
|||||||
"unicase",
|
"unicase",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pulley-interpreter"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2d9880c1985ccccaed3646b0ef793dc39a4b117403ed4afc6fa3ef6027c5200f"
|
|
||||||
dependencies = [
|
|
||||||
"cranelift-bitset",
|
|
||||||
"log",
|
|
||||||
"pulley-macros",
|
|
||||||
"wasmtime-internal-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pulley-macros"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ee249346855ad102580e474da5463f86f8a7d449e6d49e00fefb304e448e2983"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quinn"
|
name = "quinn"
|
||||||
version = "0.11.9"
|
version = "0.11.9"
|
||||||
@@ -3824,20 +3552,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "regalloc2"
|
|
||||||
version = "0.15.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186"
|
|
||||||
dependencies = [
|
|
||||||
"allocator-api2",
|
|
||||||
"bumpalo",
|
|
||||||
"hashbrown 0.17.1",
|
|
||||||
"log",
|
|
||||||
"rustc-hash",
|
|
||||||
"smallvec",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.12.3"
|
version = "1.12.3"
|
||||||
@@ -3971,12 +3685,6 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustc-demangle"
|
|
||||||
version = "0.1.27"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustc-hash"
|
name = "rustc-hash"
|
||||||
version = "2.1.2"
|
version = "2.1.2"
|
||||||
@@ -4216,10 +3924,6 @@ name = "semver"
|
|||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2"
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
@@ -4393,6 +4097,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"session-store",
|
"session-store",
|
||||||
|
"tempfile",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4536,9 +4241,6 @@ name = "smallvec"
|
|||||||
version = "1.15.1"
|
version = "1.15.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "socket2"
|
name = "socket2"
|
||||||
@@ -4771,12 +4473,6 @@ dependencies = [
|
|||||||
"xattr",
|
"xattr",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "target-lexicon"
|
|
||||||
version = "0.13.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "target-triple"
|
name = "target-triple"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -5086,8 +4782,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tungstenite 0.29.0",
|
"tungstenite 0.29.0",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5395,6 +5095,8 @@ dependencies = [
|
|||||||
"httparse",
|
"httparse",
|
||||||
"log",
|
"log",
|
||||||
"rand 0.9.4",
|
"rand 0.9.4",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
"sha1",
|
"sha1",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
@@ -5696,27 +5398,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"leb128fmt",
|
"leb128fmt",
|
||||||
"wasmparser 0.244.0",
|
"wasmparser",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-encoder"
|
|
||||||
version = "0.248.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6"
|
|
||||||
dependencies = [
|
|
||||||
"leb128fmt",
|
|
||||||
"wasmparser 0.248.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-encoder"
|
|
||||||
version = "0.252.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f"
|
|
||||||
dependencies = [
|
|
||||||
"leb128fmt",
|
|
||||||
"wasmparser 0.252.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5727,8 +5409,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"wasm-encoder 0.244.0",
|
"wasm-encoder",
|
||||||
"wasmparser 0.244.0",
|
"wasmparser",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5756,286 +5438,6 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmparser"
|
|
||||||
version = "0.248.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.11.0",
|
|
||||||
"hashbrown 0.17.1",
|
|
||||||
"indexmap",
|
|
||||||
"semver",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmparser"
|
|
||||||
version = "0.252.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags 2.11.0",
|
|
||||||
"indexmap",
|
|
||||||
"semver",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmprinter"
|
|
||||||
version = "0.248.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "30b264a5410b008d4d199a92bf536eae703cbd614482fc1ec53831cf19e1c183"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"termcolor",
|
|
||||||
"wasmparser 0.248.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5c7ce9aa2c67f75fadcfdc6aa9097d03e7c39485dfe316f2ed6a7c0fd186c527"
|
|
||||||
dependencies = [
|
|
||||||
"addr2line",
|
|
||||||
"async-trait",
|
|
||||||
"bitflags 2.11.0",
|
|
||||||
"bumpalo",
|
|
||||||
"cc",
|
|
||||||
"cfg-if",
|
|
||||||
"encoding_rs",
|
|
||||||
"libc",
|
|
||||||
"log",
|
|
||||||
"mach2",
|
|
||||||
"memfd",
|
|
||||||
"object",
|
|
||||||
"once_cell",
|
|
||||||
"postcard",
|
|
||||||
"pulley-interpreter",
|
|
||||||
"rustix 1.1.4",
|
|
||||||
"semver",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"smallvec",
|
|
||||||
"target-lexicon",
|
|
||||||
"wasmparser 0.248.0",
|
|
||||||
"wasmtime-environ",
|
|
||||||
"wasmtime-internal-component-macro",
|
|
||||||
"wasmtime-internal-component-util",
|
|
||||||
"wasmtime-internal-core",
|
|
||||||
"wasmtime-internal-cranelift",
|
|
||||||
"wasmtime-internal-fiber",
|
|
||||||
"wasmtime-internal-jit-debug",
|
|
||||||
"wasmtime-internal-jit-icache-coherence",
|
|
||||||
"wasmtime-internal-unwinder",
|
|
||||||
"wasmtime-internal-versioned-export-macros",
|
|
||||||
"wasmtime-internal-winch",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime-environ"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c8fb157bd1fbf689ac89d570433a700db6f33bdfcb5ffc30e3f1c49e4c70de71"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"cpp_demangle",
|
|
||||||
"cranelift-bforest",
|
|
||||||
"cranelift-bitset",
|
|
||||||
"cranelift-entity",
|
|
||||||
"gimli",
|
|
||||||
"hashbrown 0.17.1",
|
|
||||||
"indexmap",
|
|
||||||
"log",
|
|
||||||
"object",
|
|
||||||
"postcard",
|
|
||||||
"rustc-demangle",
|
|
||||||
"semver",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
"smallvec",
|
|
||||||
"target-lexicon",
|
|
||||||
"wasm-encoder 0.248.0",
|
|
||||||
"wasmparser 0.248.0",
|
|
||||||
"wasmprinter",
|
|
||||||
"wasmtime-internal-component-util",
|
|
||||||
"wasmtime-internal-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime-internal-component-macro"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b96c17f35fae2ab574667aba0c58fd56349a6f788ac42541a2e543116d5cfb91"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
"wasmtime-internal-component-util",
|
|
||||||
"wasmtime-internal-wit-bindgen",
|
|
||||||
"wit-parser 0.248.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime-internal-component-util"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9d2eeb9b53222859e6f5dc73d2ccfb33254d672469cac11b693a71912e2f3817"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime-internal-core"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4a1deaf6bc3430abd7497b00c64f06ca2b97ca0fe41af87836446ca30949965c"
|
|
||||||
dependencies = [
|
|
||||||
"hashbrown 0.17.1",
|
|
||||||
"libm",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime-internal-cranelift"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b845f83b5b04b11bc48329b53eb4fa8cf9f28a43c71ed8e1203f68ffa9806d1b"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cranelift-codegen",
|
|
||||||
"cranelift-control",
|
|
||||||
"cranelift-entity",
|
|
||||||
"cranelift-frontend",
|
|
||||||
"cranelift-native",
|
|
||||||
"gimli",
|
|
||||||
"itertools",
|
|
||||||
"log",
|
|
||||||
"object",
|
|
||||||
"pulley-interpreter",
|
|
||||||
"smallvec",
|
|
||||||
"target-lexicon",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"wasmparser 0.248.0",
|
|
||||||
"wasmtime-environ",
|
|
||||||
"wasmtime-internal-core",
|
|
||||||
"wasmtime-internal-unwinder",
|
|
||||||
"wasmtime-internal-versioned-export-macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime-internal-fiber"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e10c8466f72965ae85c250f90aaa7992c089a2f8502009bd0d2c9e7d6409174a"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"rustix 1.1.4",
|
|
||||||
"wasmtime-environ",
|
|
||||||
"wasmtime-internal-versioned-export-macros",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime-internal-jit-debug"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d3adfecf5621b14d8f8871f4cb4ed9f844197b1ddefc702ef4c859552cd9551"
|
|
||||||
dependencies = [
|
|
||||||
"cc",
|
|
||||||
"wasmtime-internal-versioned-export-macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime-internal-jit-icache-coherence"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "08d3c1e9fb618ec45c9b3477ea683cd37bee427273d7b13bba5c66a1caaf1dd6"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"wasmtime-internal-core",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime-internal-unwinder"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7aa91132b81f1e172ec7e7c3c114ac34209ee6b3524b3a8d6943af99803f66c5"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"cranelift-codegen",
|
|
||||||
"log",
|
|
||||||
"object",
|
|
||||||
"wasmtime-environ",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime-internal-versioned-export-macros"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6ea811ffe23f597cc7708327ea25d9eb018dcf760ffe15ccb7d0b27ad635de61"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime-internal-winch"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "828b66175c54a0d00b4c1c1c76658d8aa73aeb9fa3553575c5eee56d40f2eb18"
|
|
||||||
dependencies = [
|
|
||||||
"cranelift-codegen",
|
|
||||||
"gimli",
|
|
||||||
"log",
|
|
||||||
"object",
|
|
||||||
"target-lexicon",
|
|
||||||
"wasmparser 0.248.0",
|
|
||||||
"wasmtime-environ",
|
|
||||||
"wasmtime-internal-cranelift",
|
|
||||||
"winch-codegen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasmtime-internal-wit-bindgen"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4ae00896ad9bef1b3ca6401ae9a841daa6f357dd91541b6baf87082946d1bde1"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"bitflags 2.11.0",
|
|
||||||
"heck",
|
|
||||||
"indexmap",
|
|
||||||
"wit-parser 0.248.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wast"
|
|
||||||
version = "252.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65"
|
|
||||||
dependencies = [
|
|
||||||
"bumpalo",
|
|
||||||
"leb128fmt",
|
|
||||||
"memchr",
|
|
||||||
"unicode-width",
|
|
||||||
"wasm-encoder 0.252.0",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wat"
|
|
||||||
version = "1.252.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb"
|
|
||||||
dependencies = [
|
|
||||||
"wast",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "web-sys"
|
name = "web-sys"
|
||||||
version = "0.3.94"
|
version = "0.3.94"
|
||||||
@@ -6133,6 +5535,24 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "0.26.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||||
|
dependencies = [
|
||||||
|
"webpki-roots 1.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "weezl"
|
name = "weezl"
|
||||||
version = "0.1.12"
|
version = "0.1.12"
|
||||||
@@ -6242,25 +5662,6 @@ version = "0.4.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winch-codegen"
|
|
||||||
version = "45.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "89c09acfdfa281b3340e1e94ef3cf6618d69eab975280f881e154c29f49419c1"
|
|
||||||
dependencies = [
|
|
||||||
"cranelift-assembler-x64",
|
|
||||||
"cranelift-codegen",
|
|
||||||
"gimli",
|
|
||||||
"regalloc2",
|
|
||||||
"smallvec",
|
|
||||||
"target-lexicon",
|
|
||||||
"thiserror 2.0.18",
|
|
||||||
"wasmparser 0.248.0",
|
|
||||||
"wasmtime-environ",
|
|
||||||
"wasmtime-internal-core",
|
|
||||||
"wasmtime-internal-cranelift",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-core"
|
name = "windows-core"
|
||||||
version = "0.62.2"
|
version = "0.62.2"
|
||||||
@@ -6524,7 +5925,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"heck",
|
"heck",
|
||||||
"wit-parser 0.244.0",
|
"wit-parser",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6571,10 +5972,10 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"wasm-encoder 0.244.0",
|
"wasm-encoder",
|
||||||
"wasm-metadata",
|
"wasm-metadata",
|
||||||
"wasmparser 0.244.0",
|
"wasmparser",
|
||||||
"wit-parser 0.244.0",
|
"wit-parser",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6592,26 +5993,7 @@ dependencies = [
|
|||||||
"serde_derive",
|
"serde_derive",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"unicode-xid",
|
"unicode-xid",
|
||||||
"wasmparser 0.244.0",
|
"wasmparser",
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wit-parser"
|
|
||||||
version = "0.248.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "247ad505da2915a082fe13204c5ba8788425aea1de54f43b284818cf82637856"
|
|
||||||
dependencies = [
|
|
||||||
"anyhow",
|
|
||||||
"hashbrown 0.17.1",
|
|
||||||
"id-arena",
|
|
||||||
"indexmap",
|
|
||||||
"log",
|
|
||||||
"semver",
|
|
||||||
"serde",
|
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
|
||||||
"unicode-xid",
|
|
||||||
"wasmparser 0.248.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6674,11 +6056,8 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tungstenite 0.28.0",
|
"tungstenite 0.28.0",
|
||||||
"uuid",
|
"uuid",
|
||||||
"wasmtime",
|
|
||||||
"wat",
|
|
||||||
"workdir",
|
"workdir",
|
||||||
"workspace-api",
|
"workspace-api",
|
||||||
"yoi-plugin-pdk",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -6691,6 +6070,7 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"decodal",
|
"decodal",
|
||||||
"flow",
|
"flow",
|
||||||
|
"fs4",
|
||||||
"futures",
|
"futures",
|
||||||
"manifest",
|
"manifest",
|
||||||
"protocol",
|
"protocol",
|
||||||
@@ -6798,6 +6178,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"tui",
|
"tui",
|
||||||
|
"uuid",
|
||||||
"worker",
|
"worker",
|
||||||
"workspace-api",
|
"workspace-api",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub use workspace_api::{
|
|||||||
WorkerLaunchOptionsResponse as BackendWorkerLaunchOptions,
|
WorkerLaunchOptionsResponse as BackendWorkerLaunchOptions,
|
||||||
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
|
WorkerLaunchProfileCandidate as BackendWorkerLaunchProfileCandidate,
|
||||||
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
|
WorkerLaunchRuntimeOption as BackendWorkerLaunchRuntimeOption,
|
||||||
|
WorkerOperationState as BackendWorkerOperationState,
|
||||||
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
WorkerRestoreResponse as BackendWorkerRestoreResponse,
|
||||||
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
|
WorkerRestoreResult as BackendWorkerRestoreResult, WorkerSummary as BackendWorkerSummary,
|
||||||
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
|
WorkerWorkspaceSummary as BackendWorkerWorkspaceSummary,
|
||||||
@@ -913,7 +914,7 @@ mod tests {
|
|||||||
"working_directory": {
|
"working_directory": {
|
||||||
"working_directory_id": "wd-1",
|
"working_directory_id": "wd-1",
|
||||||
"repository_key": "main",
|
"repository_key": "main",
|
||||||
"materializer_kind": "local_git_worktree",
|
"materializer_kind": "runtime_git_clone",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"occupied_by": {
|
"occupied_by": {
|
||||||
"runtime_id": "arcadia",
|
"runtime_id": "arcadia",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
use crate::{BackendApiClient, BackendApiClientError};
|
use crate::{BackendApiClient, BackendApiClientError};
|
||||||
use reqwest::Method;
|
use reqwest::Method;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use workspace_api::{
|
use workspace_api::{
|
||||||
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceRepositoryRecord,
|
InitialRepositoryIntent, RepositoryListResponse, RepositorySummary,
|
||||||
|
WorkspaceCatalogListResponse, WorkspaceCreateRequest, WorkspaceCreateResponse,
|
||||||
WorkspaceSummary,
|
WorkspaceSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -11,23 +11,8 @@ const DEFAULT_WORKSPACE_LIMIT: usize = 200;
|
|||||||
|
|
||||||
pub type BackendWorkspace = WorkspaceSummary;
|
pub type BackendWorkspace = WorkspaceSummary;
|
||||||
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
|
pub type CreateBackendWorkspaceResponse = WorkspaceCreateResponse;
|
||||||
pub type CreateBackendWorkspaceRepositoryRecord = WorkspaceRepositoryRecord;
|
pub type CreateBackendWorkspaceRequest = WorkspaceCreateRequest;
|
||||||
|
pub type CreateBackendWorkspaceRepository = InitialRepositoryIntent;
|
||||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct CreateBackendWorkspaceRequest {
|
|
||||||
pub operation_key: String,
|
|
||||||
pub display_name: String,
|
|
||||||
pub repository: CreateBackendWorkspaceRepository,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct CreateBackendWorkspaceRepository {
|
|
||||||
pub uri: String,
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
pub default_ref: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct BackendWorkspaceCatalogTarget {
|
pub struct BackendWorkspaceCatalogTarget {
|
||||||
@@ -73,6 +58,48 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_backend_workspaces_blocking(
|
||||||
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
|
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||||
|
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
|
let response = client
|
||||||
|
.blocking_request(
|
||||||
|
Method::GET,
|
||||||
|
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
|
||||||
|
)?
|
||||||
|
.send()?;
|
||||||
|
client.check_status(response.status())?;
|
||||||
|
Ok(response.json::<WorkspaceCatalogListResponse>()?.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_backend_workspace_repositories_blocking(
|
||||||
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> Result<Vec<RepositorySummary>, BackendWorkspaceClientError> {
|
||||||
|
if workspace_id.is_empty()
|
||||||
|
|| workspace_id.len() > 200
|
||||||
|
|| !workspace_id
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||||
|
{
|
||||||
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
|
"Workspace id returned by Backend is invalid".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let client = BackendApiClient::from_stored_token(&target.base_url)?;
|
||||||
|
let response = client
|
||||||
|
.blocking_request(Method::GET, &format!("/api/w/{workspace_id}/repositories"))?
|
||||||
|
.send()?;
|
||||||
|
client.check_status(response.status())?;
|
||||||
|
let response = response.json::<RepositoryListResponse>()?;
|
||||||
|
if response.workspace_id != workspace_id {
|
||||||
|
return Err(BackendWorkspaceClientError::InvalidTarget(
|
||||||
|
"Repository catalog response does not match the requested Workspace".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(response.items)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_backend_workspaces(
|
pub async fn list_backend_workspaces(
|
||||||
target: &BackendWorkspaceCatalogTarget,
|
target: &BackendWorkspaceCatalogTarget,
|
||||||
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
) -> Result<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
|
||||||
@@ -149,8 +176,8 @@ mod tests {
|
|||||||
operation_key: "workspace-create-1".to_string(),
|
operation_key: "workspace-create-1".to_string(),
|
||||||
display_name: "Alpha".to_string(),
|
display_name: "Alpha".to_string(),
|
||||||
repository: CreateBackendWorkspaceRepository {
|
repository: CreateBackendWorkspaceRepository {
|
||||||
|
repository_key: "main".to_string(),
|
||||||
uri: "/srv/repos/alpha".to_string(),
|
uri: "/srv/repos/alpha".to_string(),
|
||||||
display_name: Some("Main".to_string()),
|
|
||||||
default_ref: Some("develop".to_string()),
|
default_ref: Some("develop".to_string()),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,15 +26,18 @@ pub use backend_runtime::{
|
|||||||
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
BackendRuntimeListTarget, BackendRuntimeSummary, BackendRuntimeTarget,
|
||||||
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
|
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary, BackendWorkerLaunchOptions,
|
||||||
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
|
BackendWorkerLaunchProfileCandidate, BackendWorkerLaunchRuntimeOption,
|
||||||
BackendWorkerLaunchTarget, BackendWorkerRestoreResponse, BackendWorkerRestoreResult,
|
BackendWorkerLaunchTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
|
||||||
BackendWorkerSummary, BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary,
|
BackendWorkerRestoreResult, BackendWorkerSummary, BackendWorkerWorkspaceSummary,
|
||||||
connect_backend_runtime, create_backend_worker, get_backend_worker_launch_options,
|
BackendWorkingDirectorySummary, connect_backend_runtime, create_backend_worker,
|
||||||
list_backend_stopped_workers, list_backend_workers, restore_backend_worker,
|
get_backend_worker_launch_options, list_backend_stopped_workers, list_backend_workers,
|
||||||
|
restore_backend_worker,
|
||||||
};
|
};
|
||||||
pub use backend_workspace::{
|
pub use backend_workspace::{
|
||||||
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError,
|
||||||
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
|
||||||
CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces,
|
CreateBackendWorkspaceResponse, create_backend_workspace,
|
||||||
|
list_backend_workspace_repositories_blocking, list_backend_workspaces,
|
||||||
|
list_backend_workspaces_blocking,
|
||||||
};
|
};
|
||||||
pub use client::{Client, ClientError};
|
pub use client::{Client, ClientError};
|
||||||
pub use target::{
|
pub use target::{
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ use workspace_api::{
|
|||||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||||
ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest,
|
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
|
||||||
RevokeRuntimeTrustKeyRequest, RuntimeTrustKeyRevealResponse,
|
RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
|
||||||
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
|
WorkspaceRuntimeResource,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||||
@@ -266,18 +266,6 @@ impl BackendWorkspaceProductClient {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn put_runtime_trust_key(
|
|
||||||
&self,
|
|
||||||
runtime_id: &str,
|
|
||||||
request: &PutRuntimeTrustKeyRequest,
|
|
||||||
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
|
||||||
self.send_json(
|
|
||||||
Method::PUT,
|
|
||||||
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
|
|
||||||
Some(request),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn revoke_runtime_trust_key(
|
pub fn revoke_runtime_trust_key(
|
||||||
&self,
|
&self,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
|
|||||||
@@ -9,14 +9,21 @@ fn workspace_creation_request_preserves_operation_key_for_retry() {
|
|||||||
operation_key: "workspace-create-1".to_string(),
|
operation_key: "workspace-create-1".to_string(),
|
||||||
display_name: "Alpha".to_string(),
|
display_name: "Alpha".to_string(),
|
||||||
repository: CreateBackendWorkspaceRepository {
|
repository: CreateBackendWorkspaceRepository {
|
||||||
|
repository_key: "main".to_string(),
|
||||||
uri: "/srv/repos/alpha".to_string(),
|
uri: "/srv/repos/alpha".to_string(),
|
||||||
display_name: Some("Main".to_string()),
|
|
||||||
default_ref: Some("develop".to_string()),
|
default_ref: Some("develop".to_string()),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(request.clone(), request);
|
assert_eq!(request.clone(), request);
|
||||||
assert_eq!(request.operation_key, "workspace-create-1");
|
assert_eq!(request.operation_key, "workspace-create-1");
|
||||||
|
let json = serde_json::to_value(&request).unwrap();
|
||||||
|
assert_eq!(json["operation_key"], "workspace-create-1");
|
||||||
|
assert_eq!(json["repository"]["repository_key"], "main");
|
||||||
|
assert_eq!(json["repository"]["uri"], "/srv/repos/alpha");
|
||||||
|
assert!(json.get("operation_id").is_none());
|
||||||
|
assert!(json["repository"].get("display_name").is_none());
|
||||||
|
assert!(json["repository"].get("source").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use globset::Glob;
|
use globset::Glob;
|
||||||
use ignore::WalkBuilder;
|
use ignore::WalkBuilder;
|
||||||
|
|
||||||
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, direct_symlink};
|
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, resolve_access_path};
|
||||||
|
|
||||||
/// Execute a bounded glob entirely inside the provider process.
|
/// Execute a bounded glob entirely inside the provider process.
|
||||||
pub fn run_glob(
|
pub fn run_glob(
|
||||||
@@ -15,26 +15,24 @@ pub fn run_glob(
|
|||||||
if !root.is_absolute() {
|
if !root.is_absolute() {
|
||||||
return Err(FsError::RelativePath(root.to_path_buf()));
|
return Err(FsError::RelativePath(root.to_path_buf()));
|
||||||
}
|
}
|
||||||
if !access.is_readable(base) {
|
let base_resolved = resolve_access_path(base).map_err(|error| FsError::Io {
|
||||||
|
path: PathBuf::from(request.path.as_str()),
|
||||||
|
source: error,
|
||||||
|
})?;
|
||||||
|
if !access.is_readable_paths(base, &base_resolved) {
|
||||||
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str())));
|
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str())));
|
||||||
}
|
}
|
||||||
if let Some(info) = direct_symlink(base)
|
|
||||||
&& info.target_exists
|
|
||||||
&& info.resolved_path.is_dir()
|
|
||||||
{
|
|
||||||
return Err(FsError::SymlinkDirectoryNotTraversed {
|
|
||||||
tool: "Glob",
|
|
||||||
path: PathBuf::from(request.path.as_str()),
|
|
||||||
target: PathBuf::from("<provider-internal target>"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let matcher = Glob::new(&request.pattern)
|
let matcher = Glob::new(&request.pattern)
|
||||||
.map_err(|error| FsError::InvalidGlob(error.to_string()))?
|
.map_err(|error| FsError::InvalidGlob(error.to_string()))?
|
||||||
.compile_matcher();
|
.compile_matcher();
|
||||||
let mut matches = Vec::new();
|
let mut matches = Vec::new();
|
||||||
for entry in WalkBuilder::new(base).hidden(false).build().flatten() {
|
let mut walker = WalkBuilder::new(base);
|
||||||
|
walker.hidden(false).follow_links(false);
|
||||||
|
for entry in walker.build().flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !path.is_file() || !access.is_readable(path) {
|
let readable = resolve_access_path(path)
|
||||||
|
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
|
||||||
|
if !path.is_file() || !readable {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let relative = path.strip_prefix(base).unwrap_or(path);
|
let relative = path.strip_prefix(base).unwrap_or(path);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
pub use glob::run_glob;
|
pub use glob::run_glob;
|
||||||
pub use local::{run_edit, run_list, run_read, run_stat, run_write};
|
pub use local::{resolve_access_path, run_edit, run_list, run_read, run_stat, run_write};
|
||||||
pub use operation::*;
|
pub use operation::*;
|
||||||
pub use search::run_grep;
|
pub use search::run_grep;
|
||||||
|
|
||||||
@@ -22,6 +22,19 @@ pub use search::run_grep;
|
|||||||
pub trait FsAccessPolicy: Send + Sync {
|
pub trait FsAccessPolicy: Send + Sync {
|
||||||
fn is_readable(&self, path: &Path) -> bool;
|
fn is_readable(&self, path: &Path) -> bool;
|
||||||
fn is_writable(&self, path: &Path) -> bool;
|
fn is_writable(&self, path: &Path) -> bool;
|
||||||
|
|
||||||
|
/// Authorize both the Workdir-visible path and its provider-resolved
|
||||||
|
/// target. Implementations that do not distinguish symbolic-link identity
|
||||||
|
/// retain resolved-target semantics through the defaults.
|
||||||
|
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
||||||
|
let _ = logical;
|
||||||
|
self.is_readable(resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
||||||
|
let _ = logical;
|
||||||
|
self.is_writable(resolved)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// First symlink encountered while resolving a provider path.
|
/// First symlink encountered while resolving a provider path.
|
||||||
@@ -477,13 +490,14 @@ mod tests {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn grep_keeps_direct_symlink_directory_and_broken_path_guards() {
|
fn grep_traverses_a_direct_symlink_directory_and_rejects_a_broken_path() {
|
||||||
use std::os::unix::fs::symlink;
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let root = temp.path().canonicalize().unwrap();
|
let root = temp.path().canonicalize().unwrap();
|
||||||
let readable = RootAccess(root.clone());
|
let readable = RootAccess(root.clone());
|
||||||
std::fs::create_dir(root.join("target-dir")).unwrap();
|
std::fs::create_dir(root.join("target-dir")).unwrap();
|
||||||
|
std::fs::write(root.join("target-dir/nested.rs"), "needle nested\n").unwrap();
|
||||||
std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap();
|
std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap();
|
||||||
symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap();
|
symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap();
|
||||||
symlink(root.join("target-dir"), root.join("directory-link")).unwrap();
|
symlink(root.join("target-dir"), root.join("directory-link")).unwrap();
|
||||||
@@ -501,18 +515,35 @@ mod tests {
|
|||||||
assert_eq!(file_result.match_count, 1);
|
assert_eq!(file_result.match_count, 1);
|
||||||
assert!(file_result.output.starts_with("file-link.rs\n"));
|
assert!(file_result.output.starts_with("file-link.rs\n"));
|
||||||
|
|
||||||
let directory_error = run_grep(
|
let directory_result = run_grep(
|
||||||
&root,
|
&root,
|
||||||
root.join("directory-link"),
|
root.join("directory-link"),
|
||||||
request("directory-link"),
|
request("directory-link"),
|
||||||
&readable,
|
&readable,
|
||||||
)
|
)
|
||||||
.unwrap_err();
|
.unwrap();
|
||||||
assert!(matches!(
|
assert_eq!(directory_result.match_count, 1);
|
||||||
directory_error,
|
assert!(
|
||||||
FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. }
|
directory_result
|
||||||
if path == root.join("directory-link")
|
.output
|
||||||
));
|
.starts_with("directory-link/nested.rs\n")
|
||||||
|
);
|
||||||
|
|
||||||
|
let glob_result = run_glob(
|
||||||
|
&root,
|
||||||
|
&root.join("directory-link"),
|
||||||
|
GlobRequest {
|
||||||
|
pattern: "**/*.rs".to_string(),
|
||||||
|
path: FsPath::new("directory-link").unwrap(),
|
||||||
|
limit: 10,
|
||||||
|
},
|
||||||
|
&readable,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
glob_result.paths,
|
||||||
|
vec![FsPath::new("directory-link/nested.rs").unwrap()]
|
||||||
|
);
|
||||||
|
|
||||||
let broken_error = run_grep(
|
let broken_error = run_grep(
|
||||||
&root,
|
&root,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use std::ffi::OsString;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -18,7 +19,8 @@ pub fn run_stat(
|
|||||||
) -> Result<StatResult, FsError> {
|
) -> Result<StatResult, FsError> {
|
||||||
let logical = request.path;
|
let logical = request.path;
|
||||||
let path = resolve(root, &logical)?;
|
let path = resolve(root, &logical)?;
|
||||||
if !access.is_readable(&path) {
|
let resolved = resolve_access_path(&path).map_err(|error| map_io(&logical, error))?;
|
||||||
|
if !access.is_readable_paths(&path, &resolved) {
|
||||||
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
|
return Err(FsError::OutOfScope(PathBuf::from(logical.as_str())));
|
||||||
}
|
}
|
||||||
let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?;
|
let metadata = fs::symlink_metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||||
@@ -45,7 +47,7 @@ pub fn run_read(
|
|||||||
) -> Result<ReadResult, FsError> {
|
) -> Result<ReadResult, FsError> {
|
||||||
let logical = request.path;
|
let logical = request.path;
|
||||||
let path = resolve(root, &logical)?;
|
let path = resolve(root, &logical)?;
|
||||||
let path = require_access(&path, &logical, access, false)?;
|
let path = require_access(&path, &logical, access, false, false)?;
|
||||||
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||||
if metadata.is_dir() {
|
if metadata.is_dir() {
|
||||||
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
||||||
@@ -99,7 +101,7 @@ pub fn run_write(
|
|||||||
let path = resolve(root, &logical)?;
|
let path = resolve(root, &logical)?;
|
||||||
let created = !path.exists();
|
let created = !path.exists();
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
let target = require_access(&path, &logical, access, true)?;
|
let target = require_access(&path, &logical, access, true, false)?;
|
||||||
let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
|
let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
|
||||||
if metadata.is_dir() {
|
if metadata.is_dir() {
|
||||||
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
|
||||||
@@ -113,12 +115,8 @@ pub fn run_write(
|
|||||||
if request.expected_hash.is_some() {
|
if request.expected_hash.is_some() {
|
||||||
return Err(FsError::Conflict(logical.as_str().to_string()));
|
return Err(FsError::Conflict(logical.as_str().to_string()));
|
||||||
}
|
}
|
||||||
let parent = path.parent().ok_or_else(|| {
|
let target = require_access(&path, &logical, access, true, true)?;
|
||||||
FsError::InvalidArgument(format!("{} has no parent", logical.as_str()))
|
atomic_write(&target, &request.content, &logical)?;
|
||||||
})?;
|
|
||||||
let parent_logical = logical_parent(&logical);
|
|
||||||
require_access(parent, &parent_logical, access, true)?;
|
|
||||||
atomic_write(&path, &request.content, &logical)?;
|
|
||||||
}
|
}
|
||||||
Ok(WriteResult {
|
Ok(WriteResult {
|
||||||
bytes_written: request.content.len(),
|
bytes_written: request.content.len(),
|
||||||
@@ -133,7 +131,7 @@ pub fn run_edit(
|
|||||||
) -> Result<EditResult, FsError> {
|
) -> Result<EditResult, FsError> {
|
||||||
let logical = request.path;
|
let logical = request.path;
|
||||||
let path = resolve(root, &logical)?;
|
let path = resolve(root, &logical)?;
|
||||||
let target = require_access(&path, &logical, access, true)?;
|
let target = require_access(&path, &logical, access, true, false)?;
|
||||||
let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
|
let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
|
||||||
let actual_hash = hash_bytes(&bytes);
|
let actual_hash = hash_bytes(&bytes);
|
||||||
if actual_hash != request.expected_hash {
|
if actual_hash != request.expected_hash {
|
||||||
@@ -173,7 +171,8 @@ pub fn run_list(
|
|||||||
) -> Result<ListResult, FsError> {
|
) -> Result<ListResult, FsError> {
|
||||||
let logical = request.path;
|
let logical = request.path;
|
||||||
let path = resolve(root, &logical)?;
|
let path = resolve(root, &logical)?;
|
||||||
let path = require_access(&path, &logical, access, false)?;
|
let logical_base = path.clone();
|
||||||
|
let path = require_access(&path, &logical, access, false, true)?;
|
||||||
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
|
||||||
if !metadata.is_dir() {
|
if !metadata.is_dir() {
|
||||||
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
|
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
|
||||||
@@ -183,7 +182,15 @@ pub fn run_list(
|
|||||||
for entry in read_dir {
|
for entry in read_dir {
|
||||||
let entry = entry.map_err(|error| map_io(&logical, error))?;
|
let entry = entry.map_err(|error| map_io(&logical, error))?;
|
||||||
let absolute = entry.path();
|
let absolute = entry.path();
|
||||||
if !access.is_readable(&absolute) {
|
let relative_to_base = absolute.strip_prefix(&path).map_err(|_| {
|
||||||
|
FsError::InvalidArgument("provider returned a path outside its list base".to_string())
|
||||||
|
})?;
|
||||||
|
let logical_absolute = logical_base.join(relative_to_base);
|
||||||
|
let resolved = match resolve_access_path(&absolute) {
|
||||||
|
Ok(resolved) => resolved,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if !access.is_readable_paths(&logical_absolute, &resolved) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let link_metadata =
|
let link_metadata =
|
||||||
@@ -203,7 +210,7 @@ pub fn run_list(
|
|||||||
} else {
|
} else {
|
||||||
EntryKind::Other
|
EntryKind::Other
|
||||||
};
|
};
|
||||||
let relative = absolute.strip_prefix(root).map_err(|_| {
|
let relative = logical_absolute.strip_prefix(root).map_err(|_| {
|
||||||
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
FsError::InvalidArgument("provider returned a path outside its root".to_string())
|
||||||
})?;
|
})?;
|
||||||
entries.push(ListEntry {
|
entries.push(ListEntry {
|
||||||
@@ -247,19 +254,24 @@ fn require_access(
|
|||||||
logical: &FsPath,
|
logical: &FsPath,
|
||||||
access: &dyn FsAccessPolicy,
|
access: &dyn FsAccessPolicy,
|
||||||
write: bool,
|
write: bool,
|
||||||
|
allow_symlink_directory: bool,
|
||||||
) -> Result<PathBuf, FsError> {
|
) -> Result<PathBuf, FsError> {
|
||||||
if let Some(info) = direct_symlink(path) {
|
let symlink = direct_symlink(path);
|
||||||
if !info.target_exists {
|
if let Some(info) = symlink.as_ref()
|
||||||
return Err(FsError::BrokenSymlink {
|
&& !info.target_exists
|
||||||
path: PathBuf::from(logical.as_str()),
|
{
|
||||||
link: PathBuf::from(logical.as_str()),
|
return Err(FsError::BrokenSymlink {
|
||||||
target: PathBuf::from("<provider-internal target>"),
|
path: PathBuf::from(logical.as_str()),
|
||||||
});
|
link: PathBuf::from(logical.as_str()),
|
||||||
}
|
target: PathBuf::from("<provider-internal target>"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let resolved = resolve_access_path(path).map_err(|error| map_io(logical, error))?;
|
||||||
|
if let Some(info) = symlink {
|
||||||
let allowed = if write {
|
let allowed = if write {
|
||||||
access.is_writable(&info.resolved_path)
|
access.is_writable_paths(path, &resolved)
|
||||||
} else {
|
} else {
|
||||||
access.is_readable(&info.resolved_path)
|
access.is_readable_paths(path, &resolved)
|
||||||
};
|
};
|
||||||
if !allowed {
|
if !allowed {
|
||||||
return Err(FsError::SymlinkOutOfScope {
|
return Err(FsError::SymlinkOutOfScope {
|
||||||
@@ -268,21 +280,21 @@ fn require_access(
|
|||||||
required_permission: if write { "write" } else { "read" },
|
required_permission: if write { "write" } else { "read" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if write && info.resolved_path.is_dir() {
|
if !allow_symlink_directory && info.resolved_path.is_dir() {
|
||||||
return Err(FsError::SymlinkTargetIsDirectory {
|
return Err(FsError::SymlinkTargetIsDirectory {
|
||||||
path: PathBuf::from(logical.as_str()),
|
path: PathBuf::from(logical.as_str()),
|
||||||
target: PathBuf::from("<provider-internal target>"),
|
target: PathBuf::from("<provider-internal target>"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Ok(info.resolved_path);
|
return Ok(resolved);
|
||||||
}
|
}
|
||||||
let allowed = if write {
|
let allowed = if write {
|
||||||
access.is_writable(path)
|
access.is_writable_paths(path, &resolved)
|
||||||
} else {
|
} else {
|
||||||
access.is_readable(path)
|
access.is_readable_paths(path, &resolved)
|
||||||
};
|
};
|
||||||
if allowed {
|
if allowed {
|
||||||
Ok(path.to_path_buf())
|
Ok(resolved)
|
||||||
} else if write {
|
} else if write {
|
||||||
Err(FsError::ReadOnly(PathBuf::from(logical.as_str())))
|
Err(FsError::ReadOnly(PathBuf::from(logical.as_str())))
|
||||||
} else {
|
} else {
|
||||||
@@ -290,12 +302,38 @@ fn require_access(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn logical_parent(path: &FsPath) -> FsPath {
|
/// Resolve every existing component of an absolute provider path while
|
||||||
let parent = Path::new(path.as_str())
|
/// retaining a missing final tail for create operations. Dangling symlinks are
|
||||||
.parent()
|
/// rejected because no resolved authority identity can be established.
|
||||||
.unwrap_or_else(|| Path::new(""))
|
pub fn resolve_access_path(path: &Path) -> std::io::Result<PathBuf> {
|
||||||
.to_string_lossy();
|
let mut cursor = path;
|
||||||
FsPath::new(parent).unwrap_or_else(|_| FsPath::root())
|
let mut missing = Vec::<OsString>::new();
|
||||||
|
loop {
|
||||||
|
match fs::canonicalize(cursor) {
|
||||||
|
Ok(mut resolved) => {
|
||||||
|
for component in missing.iter().rev() {
|
||||||
|
resolved.push(component);
|
||||||
|
}
|
||||||
|
return Ok(resolved);
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
if fs::symlink_metadata(cursor)
|
||||||
|
.is_ok_and(|metadata| metadata.file_type().is_symlink())
|
||||||
|
{
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
let name = cursor.file_name().ok_or(error)?;
|
||||||
|
missing.push(name.to_os_string());
|
||||||
|
cursor = cursor.parent().ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"path has no existing ancestor",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> {
|
fn atomic_write(path: &Path, content: &[u8], logical: &FsPath) -> Result<(), FsError> {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ use ignore::WalkBuilder;
|
|||||||
use ignore::overrides::{Override, OverrideBuilder};
|
use ignore::overrides::{Override, OverrideBuilder};
|
||||||
use ignore::types::{Types, TypesBuilder};
|
use ignore::types::{Types, TypesBuilder};
|
||||||
|
|
||||||
use crate::{FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink};
|
use crate::{
|
||||||
|
FsError, GrepOutputMode, GrepRequest, GrepResult, direct_symlink, resolve_access_path,
|
||||||
|
};
|
||||||
|
|
||||||
struct ContentLine {
|
struct ContentLine {
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
@@ -220,14 +222,28 @@ pub fn run_grep(
|
|||||||
return Err(FsError::RelativePath(base));
|
return Err(FsError::RelativePath(base));
|
||||||
}
|
}
|
||||||
let symlink = direct_symlink(&base);
|
let symlink = direct_symlink(&base);
|
||||||
if !access.is_readable(&base) {
|
if let Some(info) = symlink.as_ref()
|
||||||
|
&& !info.target_exists
|
||||||
|
{
|
||||||
|
return Err(FsError::BrokenSymlink {
|
||||||
|
path: base.clone(),
|
||||||
|
link: info.link_path.clone(),
|
||||||
|
target: info.resolved_path.clone(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let resolved_base = resolve_access_path(&base).map_err(|error| FsError::io(&base, error))?;
|
||||||
|
if !access.is_readable_paths(&base, &resolved_base) {
|
||||||
return Err(if let Some(info) = symlink.as_ref() {
|
return Err(if let Some(info) = symlink.as_ref() {
|
||||||
let link_parent_readable = info
|
let link_parent_readable = info
|
||||||
.link_path
|
.link_path
|
||||||
.parent()
|
.parent()
|
||||||
.map(|parent| access.is_readable(parent))
|
.and_then(|parent| {
|
||||||
|
resolve_access_path(parent)
|
||||||
|
.ok()
|
||||||
|
.map(|resolved| access.is_readable_paths(parent, &resolved))
|
||||||
|
})
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if info.target_exists && link_parent_readable {
|
if link_parent_readable {
|
||||||
FsError::SymlinkOutOfScope {
|
FsError::SymlinkOutOfScope {
|
||||||
path: base.clone(),
|
path: base.clone(),
|
||||||
target: info.resolved_path.clone(),
|
target: info.resolved_path.clone(),
|
||||||
@@ -240,15 +256,6 @@ pub fn run_grep(
|
|||||||
FsError::OutOfScope(base.clone())
|
FsError::OutOfScope(base.clone())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if let Some(info) = symlink.as_ref() {
|
|
||||||
if !info.target_exists {
|
|
||||||
return Err(FsError::BrokenSymlink {
|
|
||||||
path: base.clone(),
|
|
||||||
link: info.link_path.clone(),
|
|
||||||
target: info.target_path.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
|
let base_meta = std::fs::metadata(&base).map_err(|e| match e.kind() {
|
||||||
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
|
std::io::ErrorKind::NotFound => FsError::NotFound(base.clone()),
|
||||||
_ => FsError::io(&base, e),
|
_ => FsError::io(&base, e),
|
||||||
@@ -259,16 +266,6 @@ pub fn run_grep(
|
|||||||
base.display()
|
base.display()
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
if base_meta.is_dir()
|
|
||||||
&& let Some(info) = symlink.as_ref()
|
|
||||||
{
|
|
||||||
return Err(FsError::SymlinkDirectoryNotTraversed {
|
|
||||||
tool: "Grep",
|
|
||||||
path: base.clone(),
|
|
||||||
target: info.resolved_path.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let filter_base = if base_meta.is_file() { root } else { &base };
|
let filter_base = if base_meta.is_file() { root } else { &base };
|
||||||
let types = build_types(p.file_type.as_deref())?;
|
let types = build_types(p.file_type.as_deref())?;
|
||||||
let overrides = build_overrides(filter_base, p.glob.as_deref())?;
|
let overrides = build_overrides(filter_base, p.glob.as_deref())?;
|
||||||
@@ -331,7 +328,9 @@ pub fn run_grep(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if !access.is_readable(path) {
|
let readable = resolve_access_path(path)
|
||||||
|
.is_ok_and(|resolved| access.is_readable_paths(path, &resolved));
|
||||||
|
if !readable {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if scan_path(
|
if scan_path(
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ use serde::{Deserialize, Serialize};
|
|||||||
|
|
||||||
use crate::defaults;
|
use crate::defaults;
|
||||||
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
use crate::model::{AuthRef, ModelManifest, ReasoningControl};
|
||||||
use crate::plugin::PluginConfig;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
CompactionConfig, EngineManifest, FeatureConfig, FeatureFlagConfig, FileUploadLimits,
|
||||||
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
|
McpConfig, McpEnvValue, McpStdioCwdPolicy, MemoryConsolidationProfileConfig,
|
||||||
@@ -55,10 +54,6 @@ pub struct WorkerManifestConfig {
|
|||||||
/// disabled after cascade merge.
|
/// disabled after cascade merge.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub feature: FeatureConfigPartial,
|
pub feature: FeatureConfigPartial,
|
||||||
/// Explicit plugin package enablement entries. Discovery/resolution is a
|
|
||||||
/// separate step and does not run during config merge.
|
|
||||||
#[serde(default)]
|
|
||||||
pub plugins: PluginConfig,
|
|
||||||
/// Explicit Model Context Protocol provider declarations. Config parsing
|
/// Explicit Model Context Protocol provider declarations. Config parsing
|
||||||
/// never starts a local MCP subprocess.
|
/// never starts a local MCP subprocess.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -74,6 +69,7 @@ pub struct WorkerManifestConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct FeatureConfigPartial {
|
pub struct FeatureConfigPartial {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub task: Option<FeatureFlagConfigPartial>,
|
pub task: Option<FeatureFlagConfigPartial>,
|
||||||
@@ -101,8 +97,6 @@ pub struct FeatureConfigPartial {
|
|||||||
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
|
pub merge_request: Option<MergeRequestFeatureConfigPartial>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub orchestration: Option<FeatureFlagConfigPartial>,
|
pub orchestration: Option<FeatureFlagConfigPartial>,
|
||||||
#[serde(default)]
|
|
||||||
pub plugins: Option<FeatureFlagConfigPartial>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FeatureConfigPartial {
|
impl FeatureConfigPartial {
|
||||||
@@ -145,7 +139,6 @@ impl FeatureConfigPartial {
|
|||||||
other.orchestration,
|
other.orchestration,
|
||||||
FeatureFlagConfigPartial::merge,
|
FeatureFlagConfigPartial::merge,
|
||||||
),
|
),
|
||||||
plugins: merge_option(self.plugins, other.plugins, FeatureFlagConfigPartial::merge),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,10 +363,6 @@ impl From<FeatureConfigPartial> for FeatureConfig {
|
|||||||
.orchestration
|
.orchestration
|
||||||
.map(FeatureFlagConfig::from)
|
.map(FeatureFlagConfig::from)
|
||||||
.unwrap_or_default(),
|
.unwrap_or_default(),
|
||||||
plugins: value
|
|
||||||
.plugins
|
|
||||||
.map(FeatureFlagConfig::from)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -517,7 +506,6 @@ impl From<FeatureConfig> for FeatureConfigPartial {
|
|||||||
ticket: Some(value.ticket.into()),
|
ticket: Some(value.ticket.into()),
|
||||||
merge_request: Some(value.merge_request.into()),
|
merge_request: Some(value.merge_request.into()),
|
||||||
orchestration: Some(value.orchestration.into()),
|
orchestration: Some(value.orchestration.into()),
|
||||||
plugins: Some(value.plugins.into()),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -654,6 +642,20 @@ pub(crate) fn reject_removed_manifest_fields(s: &str) -> Result<(), toml::de::Er
|
|||||||
"unknown field in manifest: memory (removed; configure feature.memory)",
|
"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)
|
||||||
|
.is_some_and(|table| table.contains_key("plugins"))
|
||||||
|
{
|
||||||
|
return Err(toml::de::Error::custom(
|
||||||
|
"unknown field in manifest: feature.plugins (dynamic Plugins are not supported)",
|
||||||
|
));
|
||||||
|
}
|
||||||
if value
|
if value
|
||||||
.get("feature")
|
.get("feature")
|
||||||
.and_then(toml::Value::as_table)
|
.and_then(toml::Value::as_table)
|
||||||
@@ -771,7 +773,6 @@ impl WorkerManifestConfig {
|
|||||||
PermissionConfigPartial::merge,
|
PermissionConfigPartial::merge,
|
||||||
),
|
),
|
||||||
feature: self.feature.merge(upper.feature),
|
feature: self.feature.merge(upper.feature),
|
||||||
plugins: merge_plugin_config(self.plugins, upper.plugins),
|
|
||||||
mcp: merge_mcp_config(self.mcp, upper.mcp),
|
mcp: merge_mcp_config(self.mcp, upper.mcp),
|
||||||
compaction: merge_option(
|
compaction: merge_option(
|
||||||
self.compaction,
|
self.compaction,
|
||||||
@@ -791,16 +792,6 @@ impl SkillsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn merge_plugin_config(mut base: PluginConfig, upper: PluginConfig) -> PluginConfig {
|
|
||||||
let upper_has_resolved_plan = upper.has_resolved_plan();
|
|
||||||
base.enabled.extend(upper.enabled);
|
|
||||||
if upper_has_resolved_plan {
|
|
||||||
base.resolved = upper.resolved;
|
|
||||||
base.diagnostics = upper.diagnostics;
|
|
||||||
}
|
|
||||||
base
|
|
||||||
}
|
|
||||||
|
|
||||||
fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
|
fn merge_mcp_config(mut base: McpConfig, upper: McpConfig) -> McpConfig {
|
||||||
base.stdio_servers.extend(upper.stdio_servers);
|
base.stdio_servers.extend(upper.stdio_servers);
|
||||||
base
|
base
|
||||||
@@ -1289,7 +1280,6 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
|||||||
session,
|
session,
|
||||||
permissions,
|
permissions,
|
||||||
feature: FeatureConfig::from(cfg.feature),
|
feature: FeatureConfig::from(cfg.feature),
|
||||||
plugins: cfg.plugins,
|
|
||||||
mcp: cfg.mcp,
|
mcp: cfg.mcp,
|
||||||
compaction,
|
compaction,
|
||||||
web: cfg.web,
|
web: cfg.web,
|
||||||
@@ -1329,13 +1319,13 @@ mod tests {
|
|||||||
target: abs("/worker"),
|
target: abs("/worker"),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
},
|
},
|
||||||
delegation_scope: ScopeConfig::default(),
|
delegation_scope: ScopeConfig::default(),
|
||||||
permissions: None,
|
permissions: None,
|
||||||
feature: FeatureConfigPartial::default(),
|
feature: FeatureConfigPartial::default(),
|
||||||
plugins: PluginConfig::default(),
|
|
||||||
mcp: McpConfig::default(),
|
mcp: McpConfig::default(),
|
||||||
session: None,
|
session: None,
|
||||||
compaction: None,
|
compaction: None,
|
||||||
@@ -1575,6 +1565,7 @@ mod tests {
|
|||||||
target: PathBuf::from("secrets"),
|
target: PathBuf::from("secrets"),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
});
|
});
|
||||||
let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
|
let resolved = cfg.resolve_paths(Path::new("/workspace/proj"));
|
||||||
assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj"));
|
assert_eq!(resolved.scope.allow[0].target, Path::new("/workspace/proj"));
|
||||||
@@ -1712,6 +1703,7 @@ mod tests {
|
|||||||
target: abs("/a"),
|
target: abs("/a"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
},
|
},
|
||||||
@@ -1723,11 +1715,13 @@ mod tests {
|
|||||||
target: abs("/b"),
|
target: abs("/b"),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: vec![ScopeRule {
|
deny: vec![ScopeRule {
|
||||||
target: abs("/a/secret"),
|
target: abs("/a/secret"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: false,
|
recursive: false,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -2091,6 +2085,7 @@ enabled = false
|
|||||||
target: abs("/worker"),
|
target: abs("/worker"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
},
|
},
|
||||||
@@ -2193,6 +2188,7 @@ enabled = true
|
|||||||
target: abs("/worker"),
|
target: abs("/worker"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
},
|
},
|
||||||
@@ -2269,6 +2265,7 @@ permission = "write"
|
|||||||
target: abs("/worker"),
|
target: abs("/worker"),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
},
|
},
|
||||||
|
|||||||
+170
-40
@@ -29,7 +29,7 @@ pub use profile::{
|
|||||||
WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value,
|
WorkspaceAuthorityRequirement, resolve_profile_artifact, resolve_profile_artifact_value,
|
||||||
validate_profile_execution_target,
|
validate_profile_execution_target,
|
||||||
};
|
};
|
||||||
pub use protocol::{Permission, ScopeRule};
|
pub use protocol::{Permission, ScopeRule, SymlinkPolicy};
|
||||||
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};
|
||||||
|
|
||||||
use std::collections::{BTreeMap, HashMap};
|
use std::collections::{BTreeMap, HashMap};
|
||||||
@@ -69,10 +69,6 @@ pub struct WorkerManifest {
|
|||||||
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
|
/// resolve disabled so Profile authors choose the exposed built-in surfaces.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub feature: FeatureConfig,
|
pub feature: FeatureConfig,
|
||||||
/// Explicit plugin package enablement. Discovery remains read-only; only
|
|
||||||
/// source-qualified entries listed here may resolve to active plugin metadata.
|
|
||||||
#[serde(default)]
|
|
||||||
pub plugins: plugin::PluginConfig,
|
|
||||||
/// Explicit external Model Context Protocol provider configuration. This
|
/// Explicit external Model Context Protocol provider configuration. This
|
||||||
/// is config data only: declaring a server never starts a subprocess or
|
/// is config data only: declaring a server never starts a subprocess or
|
||||||
/// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate
|
/// grants OS sandboxing. Runtime MCP lifecycle/registration is a separate
|
||||||
@@ -106,6 +102,7 @@ pub struct WorkerManifest {
|
|||||||
/// sessions, secrets, or resolved host state. Tool registration still applies
|
/// sessions, secrets, or resolved host state. Tool registration still applies
|
||||||
/// the normal scope, host-authority, backend, memory, and network checks.
|
/// the normal scope, host-authority, backend, memory, and network checks.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct FeatureConfig {
|
pub struct FeatureConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub task: FeatureFlagConfig,
|
pub task: FeatureFlagConfig,
|
||||||
@@ -135,8 +132,6 @@ pub struct FeatureConfig {
|
|||||||
pub merge_request: MergeRequestFeatureConfig,
|
pub merge_request: MergeRequestFeatureConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub orchestration: FeatureFlagConfig,
|
pub orchestration: FeatureFlagConfig,
|
||||||
#[serde(default)]
|
|
||||||
pub plugins: FeatureFlagConfig,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for FeatureConfig {
|
impl Default for FeatureConfig {
|
||||||
@@ -155,7 +150,6 @@ impl Default for FeatureConfig {
|
|||||||
ticket: TicketFeatureConfig::default(),
|
ticket: TicketFeatureConfig::default(),
|
||||||
merge_request: MergeRequestFeatureConfig::default(),
|
merge_request: MergeRequestFeatureConfig::default(),
|
||||||
orchestration: FeatureFlagConfig::disabled(),
|
orchestration: FeatureFlagConfig::disabled(),
|
||||||
plugins: FeatureFlagConfig::disabled(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -941,9 +935,7 @@ impl Default for CompactionConfig {
|
|||||||
|
|
||||||
impl WorkerManifest {
|
impl WorkerManifest {
|
||||||
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
pub fn requires_persisted_execution_snapshot(&self) -> bool {
|
||||||
self.profile.is_some()
|
self.profile.is_some() || self.feature.memory.workspace_settings.is_some()
|
||||||
|| self.plugins.has_resolved_plan()
|
|
||||||
|| self.feature.memory.workspace_settings.is_some()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a manifest from a TOML string.
|
/// Parse a manifest from a TOML string.
|
||||||
@@ -984,7 +976,8 @@ struct LegacyMemoryConfig {
|
|||||||
consolidation_threshold_bytes: Option<u64>,
|
consolidation_threshold_bytes: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2;
|
const RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 3;
|
||||||
|
const PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION: u64 = 2;
|
||||||
|
|
||||||
/// Serialize a resolved Worker Manifest for durable Worker-specific storage.
|
/// Serialize a resolved Worker Manifest for durable Worker-specific storage.
|
||||||
pub fn write_persisted_worker_manifest_snapshot(
|
pub fn write_persisted_worker_manifest_snapshot(
|
||||||
@@ -1014,7 +1007,9 @@ pub fn read_persisted_worker_manifest_snapshot(
|
|||||||
"resolved Worker manifest snapshot schema_version must be an integer",
|
"resolved Worker manifest snapshot schema_version must be an integer",
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
|
if version != RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
|
||||||
|
&& version != PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION
|
||||||
|
{
|
||||||
return Err(serde_json::Error::io(std::io::Error::new(
|
return Err(serde_json::Error::io(std::io::Error::new(
|
||||||
std::io::ErrorKind::InvalidData,
|
std::io::ErrorKind::InvalidData,
|
||||||
format!("unsupported resolved Worker manifest snapshot schema version {version}"),
|
format!("unsupported resolved Worker manifest snapshot schema version {version}"),
|
||||||
@@ -1026,7 +1021,7 @@ pub fn read_persisted_worker_manifest_snapshot(
|
|||||||
"resolved Worker manifest snapshot contains unknown fields",
|
"resolved Worker manifest snapshot contains unknown fields",
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
let manifest = object.get("manifest").cloned().ok_or_else(|| {
|
let mut manifest = object.get("manifest").cloned().ok_or_else(|| {
|
||||||
serde_json::Error::io(std::io::Error::new(
|
serde_json::Error::io(std::io::Error::new(
|
||||||
std::io::ErrorKind::InvalidData,
|
std::io::ErrorKind::InvalidData,
|
||||||
"resolved Worker manifest snapshot is missing manifest",
|
"resolved Worker manifest snapshot is missing manifest",
|
||||||
@@ -1041,6 +1036,9 @@ pub fn read_persisted_worker_manifest_snapshot(
|
|||||||
"current resolved Worker manifest contains removed top-level memory authority",
|
"current resolved Worker manifest contains removed top-level memory authority",
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
if version == PREVIOUS_RESOLVED_MANIFEST_SNAPSHOT_SCHEMA_VERSION {
|
||||||
|
migrate_legacy_manifest_authority(&mut manifest)?;
|
||||||
|
}
|
||||||
return validate_persisted_worker_manifest(serde_json::from_value(manifest)?);
|
return validate_persisted_worker_manifest(serde_json::from_value(manifest)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1063,6 +1061,49 @@ fn validate_persisted_worker_manifest(
|
|||||||
Ok(manifest)
|
Ok(manifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migrate_legacy_manifest_authority(
|
||||||
|
manifest: &mut serde_json::Value,
|
||||||
|
) -> Result<(), serde_json::Error> {
|
||||||
|
let root = manifest.as_object_mut().ok_or_else(|| {
|
||||||
|
serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"resolved Worker manifest must be an object",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
root.remove("plugins");
|
||||||
|
if let Some(feature) = root.get_mut("feature") {
|
||||||
|
let feature = feature.as_object_mut().ok_or_else(|| {
|
||||||
|
serde_json::Error::io(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidData,
|
||||||
|
"resolved Worker manifest feature must be an object",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
feature.remove("plugins");
|
||||||
|
feature.remove("ticket_orchestration");
|
||||||
|
if let Some(workers) = feature.remove("workers") {
|
||||||
|
feature
|
||||||
|
.entry("sub_worker".to_string())
|
||||||
|
.or_insert_with(|| workers.clone());
|
||||||
|
feature.entry("worker".to_string()).or_insert(workers);
|
||||||
|
}
|
||||||
|
if let Some(ticket) = feature
|
||||||
|
.get_mut("ticket")
|
||||||
|
.and_then(serde_json::Value::as_object_mut)
|
||||||
|
&& let Some(access) = ticket.remove("access")
|
||||||
|
&& ticket
|
||||||
|
.get("enabled")
|
||||||
|
.and_then(serde_json::Value::as_bool)
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& access.as_str() == Some("lifecycle")
|
||||||
|
{
|
||||||
|
ticket.insert("authoring".to_string(), serde_json::Value::Bool(true));
|
||||||
|
ticket.insert("thread".to_string(), serde_json::Value::Bool(true));
|
||||||
|
ticket.insert("workflow".to_string(), serde_json::Value::Bool(true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn migrate_legacy_resolved_manifest_snapshot(
|
fn migrate_legacy_resolved_manifest_snapshot(
|
||||||
mut snapshot: serde_json::Value,
|
mut snapshot: serde_json::Value,
|
||||||
) -> Result<WorkerManifest, serde_json::Error> {
|
) -> Result<WorkerManifest, serde_json::Error> {
|
||||||
@@ -1088,7 +1129,7 @@ fn migrate_legacy_resolved_manifest_snapshot(
|
|||||||
.remove("memory")
|
.remove("memory")
|
||||||
.unwrap_or_else(|| serde_json::json!({})),
|
.unwrap_or_else(|| serde_json::json!({})),
|
||||||
)?;
|
)?;
|
||||||
let enabled = legacy_feature_memory.enabled;
|
let requested_enabled = legacy_feature_memory.enabled;
|
||||||
let staging_tools = legacy_feature_memory.staging;
|
let staging_tools = legacy_feature_memory.staging;
|
||||||
|
|
||||||
let legacy_memory: LegacyMemoryConfig =
|
let legacy_memory: LegacyMemoryConfig =
|
||||||
@@ -1111,9 +1152,14 @@ fn migrate_legacy_resolved_manifest_snapshot(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
if !enabled {
|
if !requested_enabled {
|
||||||
workspace_settings = None;
|
workspace_settings = None;
|
||||||
}
|
}
|
||||||
|
// Legacy standalone manifests could enable process-local Memory without a
|
||||||
|
// Workspace-owned settings snapshot. That authority no longer exists, so
|
||||||
|
// migration safely disables Memory instead of treating the whole Worker
|
||||||
|
// snapshot as corrupt.
|
||||||
|
let enabled = requested_enabled && workspace_settings.is_some();
|
||||||
let extraction_enabled = legacy_memory.extract_threshold.is_some();
|
let extraction_enabled = legacy_memory.extract_threshold.is_some();
|
||||||
if legacy_memory.consolidation_model.is_some() {
|
if legacy_memory.consolidation_model.is_some() {
|
||||||
return Err(serde_json::Error::io(std::io::Error::new(
|
return Err(serde_json::Error::io(std::io::Error::new(
|
||||||
@@ -1159,6 +1205,7 @@ fn migrate_legacy_resolved_manifest_snapshot(
|
|||||||
.insert("workspace_settings".to_string(), workspace_settings);
|
.insert("workspace_settings".to_string(), workspace_settings);
|
||||||
}
|
}
|
||||||
feature.insert("memory".to_string(), resolved);
|
feature.insert("memory".to_string(), resolved);
|
||||||
|
migrate_legacy_manifest_authority(&mut snapshot)?;
|
||||||
validate_persisted_worker_manifest(serde_json::from_value(snapshot)?)
|
validate_persisted_worker_manifest(serde_json::from_value(snapshot)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1322,33 +1369,61 @@ model_id = "claude-sonnet-4-20250514"
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_plugin_enablement_config() {
|
fn dynamic_plugin_manifest_config_is_rejected() {
|
||||||
let toml = format!(
|
let toml = format!(
|
||||||
"{MINIMAL_REQUIRED}\n\
|
"{MINIMAL_REQUIRED}\n\
|
||||||
[[plugins.enabled]]\n\
|
[[plugins.enabled]]\n\
|
||||||
id = \"project:example\"\n\
|
id = \"project:example\"\n"
|
||||||
version = \"0.1.0\"\n\
|
|
||||||
digest = \"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n\
|
|
||||||
surfaces = [\"hook\"]\n\n\
|
|
||||||
[plugins.enabled.config]\n\
|
|
||||||
greeting = \"hello\"\n"
|
|
||||||
);
|
);
|
||||||
let manifest = WorkerManifest::from_toml(&toml).unwrap();
|
let error = WorkerManifest::from_toml(&toml).unwrap_err();
|
||||||
assert_eq!(manifest.plugins.enabled.len(), 1);
|
assert!(
|
||||||
let enabled = &manifest.plugins.enabled[0];
|
error
|
||||||
assert_eq!(enabled.id, "project:example");
|
.to_string()
|
||||||
assert_eq!(
|
.contains("dynamic Plugins are not supported"),
|
||||||
enabled.version.as_ref().map(|version| version.0.as_str()),
|
"unexpected error: {error}"
|
||||||
Some("0.1.0")
|
|
||||||
);
|
);
|
||||||
assert_eq!(enabled.surfaces, vec![plugin::PluginSurface::Hook]);
|
}
|
||||||
assert_eq!(
|
|
||||||
enabled
|
#[test]
|
||||||
.config
|
fn persisted_manifest_with_dynamic_plugin_plan_is_rejected() {
|
||||||
.as_ref()
|
let base =
|
||||||
.and_then(|value| value.get("greeting"))
|
serde_json::to_value(WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap()).unwrap();
|
||||||
.and_then(|value| value.as_str()),
|
|
||||||
Some("hello")
|
let mut top_level = base.clone();
|
||||||
|
top_level.as_object_mut().unwrap().insert(
|
||||||
|
"plugins".to_string(),
|
||||||
|
serde_json::json!({
|
||||||
|
"resolved": [{
|
||||||
|
"package_path": "/tmp/ambient.yoi-plugin"
|
||||||
|
}]
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let error = serde_json::from_value::<WorkerManifest>(top_level).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("unknown field `plugins`"));
|
||||||
|
|
||||||
|
let mut nested = base;
|
||||||
|
nested
|
||||||
|
.get_mut("feature")
|
||||||
|
.unwrap()
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert(
|
||||||
|
"plugins".to_string(),
|
||||||
|
serde_json::json!({ "enabled": true }),
|
||||||
|
);
|
||||||
|
let error = serde_json::from_value::<WorkerManifest>(nested).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("unknown field `plugins`"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dynamic_plugin_feature_flag_is_rejected() {
|
||||||
|
let toml = format!("{MINIMAL_REQUIRED}\n[feature.plugins]\nenabled = true\n");
|
||||||
|
let error = WorkerManifest::from_toml(&toml).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("dynamic Plugins are not supported"),
|
||||||
|
"unexpected error: {error}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1581,7 +1656,7 @@ model_id = "claude-sonnet-4-20250514"
|
|||||||
"Français"
|
"Français"
|
||||||
);
|
);
|
||||||
let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap();
|
let current = write_persisted_worker_manifest_snapshot(&migrated).unwrap();
|
||||||
assert_eq!(current["schema_version"], 2);
|
assert_eq!(current["schema_version"], 3);
|
||||||
assert!(current["manifest"].get("memory").is_none());
|
assert!(current["manifest"].get("memory").is_none());
|
||||||
|
|
||||||
let mut disabled =
|
let mut disabled =
|
||||||
@@ -1597,6 +1672,61 @@ model_id = "claude-sonnet-4-20250514"
|
|||||||
assert!(disabled.feature.memory.workspace_settings.is_none());
|
assert!(disabled.feature.memory.workspace_settings.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn persisted_manifest_adapter_drops_removed_plugin_authority() {
|
||||||
|
let manifest = WorkerManifest::from_toml(MINIMAL_REQUIRED).unwrap();
|
||||||
|
let mut versioned = write_persisted_worker_manifest_snapshot(&manifest).unwrap();
|
||||||
|
versioned["schema_version"] = serde_json::json!(2);
|
||||||
|
versioned["manifest"]["feature"]["plugins"] = serde_json::json!({ "enabled": true });
|
||||||
|
versioned["manifest"]["feature"]
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("sub_worker");
|
||||||
|
versioned["manifest"]["feature"]
|
||||||
|
.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.remove("worker");
|
||||||
|
versioned["manifest"]["feature"]["workers"] = serde_json::json!({ "enabled": true });
|
||||||
|
versioned["manifest"]["feature"]["ticket"] =
|
||||||
|
serde_json::json!({ "enabled": true, "access": "lifecycle" });
|
||||||
|
versioned["manifest"]["feature"]["ticket_orchestration"] =
|
||||||
|
serde_json::json!({ "enabled": false });
|
||||||
|
versioned["manifest"]["plugins"] = serde_json::json!({
|
||||||
|
"enabled": ["legacy-plugin"],
|
||||||
|
"config": { "legacy-plugin": { "legacy": true } }
|
||||||
|
});
|
||||||
|
|
||||||
|
let restored = read_persisted_worker_manifest_snapshot(versioned).unwrap();
|
||||||
|
let current = write_persisted_worker_manifest_snapshot(&restored).unwrap();
|
||||||
|
assert_eq!(current["schema_version"], 3);
|
||||||
|
assert!(current["manifest"].get("plugins").is_none());
|
||||||
|
assert!(current["manifest"]["feature"].get("plugins").is_none());
|
||||||
|
assert!(current["manifest"]["feature"].get("workers").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
current["manifest"]["feature"]["sub_worker"]["enabled"],
|
||||||
|
true
|
||||||
|
);
|
||||||
|
assert_eq!(current["manifest"]["feature"]["worker"]["enabled"], true);
|
||||||
|
assert_eq!(current["manifest"]["feature"]["ticket"]["authoring"], true);
|
||||||
|
assert_eq!(current["manifest"]["feature"]["ticket"]["thread"], true);
|
||||||
|
assert_eq!(current["manifest"]["feature"]["ticket"]["workflow"], true);
|
||||||
|
|
||||||
|
let mut legacy = serde_json::to_value(manifest).unwrap();
|
||||||
|
legacy.as_object_mut().unwrap().remove("memory");
|
||||||
|
legacy["feature"]["memory"] = serde_json::json!({
|
||||||
|
"enabled": true,
|
||||||
|
"staging": false
|
||||||
|
});
|
||||||
|
legacy["feature"]["plugins"] = serde_json::json!({ "enabled": false });
|
||||||
|
legacy["plugins"] = serde_json::json!({ "enabled": [] });
|
||||||
|
let legacy = read_persisted_worker_manifest_snapshot(legacy).unwrap();
|
||||||
|
let current = write_persisted_worker_manifest_snapshot(&legacy).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
current["manifest"]["feature"]["memory"]["profile"]["enabled"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn persisted_manifest_adapter_rejects_mixed_or_future_authority() {
|
fn persisted_manifest_adapter_rejects_mixed_or_future_authority() {
|
||||||
let manifest =
|
let manifest =
|
||||||
@@ -1639,7 +1769,7 @@ model_id = "claude-sonnet-4-20250514"
|
|||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
read_persisted_worker_manifest_snapshot(serde_json::json!({
|
read_persisted_worker_manifest_snapshot(serde_json::json!({
|
||||||
"schema_version": 3,
|
"schema_version": 4,
|
||||||
"manifest": manifest,
|
"manifest": manifest,
|
||||||
}))
|
}))
|
||||||
.is_err()
|
.is_err()
|
||||||
|
|||||||
+100
-1874
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,6 @@ use crate::config::{
|
|||||||
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
|
CompactionConfigPartial, FeatureConfigPartial, PermissionConfigPartial, SessionConfigPartial,
|
||||||
};
|
};
|
||||||
use crate::model::{AuthRef, ModelManifest};
|
use crate::model::{AuthRef, ModelManifest};
|
||||||
use crate::plugin::PluginConfig;
|
|
||||||
use crate::{
|
use crate::{
|
||||||
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig,
|
EngineManifestConfig, McpConfig, McpStdioCwdPolicy, Permission, ResolveError, ScopeConfig,
|
||||||
ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
|
ScopeRule, SkillsConfig, WebConfig, WorkerManifest, WorkerManifestConfig, WorkerMetaConfig,
|
||||||
@@ -148,7 +147,6 @@ pub enum WorkspaceAuthorityRequirement {
|
|||||||
MergeRequest,
|
MergeRequest,
|
||||||
Objective,
|
Objective,
|
||||||
Orchestration,
|
Orchestration,
|
||||||
Plugins,
|
|
||||||
Ticket,
|
Ticket,
|
||||||
Worker,
|
Worker,
|
||||||
}
|
}
|
||||||
@@ -162,7 +160,6 @@ impl fmt::Display for WorkspaceAuthorityRequirement {
|
|||||||
Self::MergeRequest => formatter.write_str("feature.merge_request"),
|
Self::MergeRequest => formatter.write_str("feature.merge_request"),
|
||||||
Self::Objective => formatter.write_str("feature.objective"),
|
Self::Objective => formatter.write_str("feature.objective"),
|
||||||
Self::Orchestration => formatter.write_str("feature.orchestration"),
|
Self::Orchestration => formatter.write_str("feature.orchestration"),
|
||||||
Self::Plugins => formatter.write_str("feature.plugins or plugin packages"),
|
|
||||||
Self::Ticket => formatter.write_str("feature.ticket"),
|
Self::Ticket => formatter.write_str("feature.ticket"),
|
||||||
Self::Worker => formatter.write_str("feature.worker"),
|
Self::Worker => formatter.write_str("feature.worker"),
|
||||||
}
|
}
|
||||||
@@ -202,9 +199,6 @@ pub fn validate_profile_execution_target(
|
|||||||
if feature.orchestration.enabled {
|
if feature.orchestration.enabled {
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::Orchestration);
|
requirements.insert(WorkspaceAuthorityRequirement::Orchestration);
|
||||||
}
|
}
|
||||||
if feature.plugins.enabled || !manifest.plugins.is_empty() {
|
|
||||||
requirements.insert(WorkspaceAuthorityRequirement::Plugins);
|
|
||||||
}
|
|
||||||
if feature.ticket.enabled
|
if feature.ticket.enabled
|
||||||
|| feature.ticket.authoring
|
|| feature.ticket.authoring
|
||||||
|| feature.ticket.thread
|
|| feature.ticket.thread
|
||||||
@@ -638,7 +632,6 @@ fn resolve_profile_value(
|
|||||||
session: profile.session,
|
session: profile.session,
|
||||||
permissions: profile.permissions,
|
permissions: profile.permissions,
|
||||||
feature: profile.feature,
|
feature: profile.feature,
|
||||||
plugins: profile.plugins,
|
|
||||||
mcp: profile.mcp,
|
mcp: profile.mcp,
|
||||||
compaction,
|
compaction,
|
||||||
web: profile.web,
|
web: profile.web,
|
||||||
@@ -684,8 +677,6 @@ struct ProfileConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
feature: FeatureConfigPartial,
|
feature: FeatureConfigPartial,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
plugins: PluginConfig,
|
|
||||||
#[serde(default)]
|
|
||||||
mcp: McpConfig,
|
mcp: McpConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
compaction: Option<serde_json::Value>,
|
compaction: Option<serde_json::Value>,
|
||||||
@@ -970,6 +961,7 @@ fn profile_scope_intent_to_config(
|
|||||||
target: workspace_base.join(path),
|
target: workspace_base.join(path),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(ScopeConfig {
|
Ok(ScopeConfig {
|
||||||
@@ -977,6 +969,7 @@ fn profile_scope_intent_to_config(
|
|||||||
target: workspace_base.to_path_buf(),
|
target: workspace_base.to_path_buf(),
|
||||||
permission,
|
permission,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny,
|
deny,
|
||||||
})
|
})
|
||||||
@@ -1267,6 +1260,51 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ambient_plugin_directories_do_not_affect_builtin_profile_resolution() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let workspace = tmp.path().join("workspace/nested");
|
||||||
|
std::fs::create_dir_all(&workspace).unwrap();
|
||||||
|
for root in [tmp.path(), tmp.path().join("workspace").as_path()] {
|
||||||
|
let package = root.join(".yoi/plugins/broken.yoi-plugin");
|
||||||
|
std::fs::create_dir_all(package.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(package, b"malformed ambient package").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let resolved = ProfileResolver::new()
|
||||||
|
.with_workspace_base(&workspace)
|
||||||
|
.resolve_for_target(
|
||||||
|
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, "default"),
|
||||||
|
ProfileResolveOptions::with_worker_name("standalone-worker"),
|
||||||
|
ProfileExecutionTarget::Standalone,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(resolved.manifest.worker.name, "standalone-worker");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_rejects_dynamic_plugin_configuration() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
for body in [
|
||||||
|
"[feature.plugins]\nenabled = true\n",
|
||||||
|
"[[plugins.enabled]]\nid = \"explicit:example\"\n",
|
||||||
|
] {
|
||||||
|
let profile = write_profile(tmp.path(), "plugin.toml", body);
|
||||||
|
let error = ProfileResolver::new()
|
||||||
|
.with_workspace_base(tmp.path())
|
||||||
|
.resolve(
|
||||||
|
&ProfileSelector::path(profile),
|
||||||
|
ProfileResolveOptions::with_worker_name("runtime-worker"),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error.to_string().contains("unknown field"),
|
||||||
|
"unexpected error: {error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn builtin_default_resolves_as_a_standalone_local_capability_profile() {
|
fn builtin_default_resolves_as_a_standalone_local_capability_profile() {
|
||||||
let tmp = TempDir::new().unwrap();
|
let tmp = TempDir::new().unwrap();
|
||||||
@@ -1305,8 +1343,6 @@ mod tests {
|
|||||||
assert!(!resolved.manifest.feature.flow.enabled);
|
assert!(!resolved.manifest.feature.flow.enabled);
|
||||||
assert!(!resolved.manifest.feature.worker.enabled);
|
assert!(!resolved.manifest.feature.worker.enabled);
|
||||||
assert!(!resolved.manifest.feature.manage_workdir.enabled);
|
assert!(!resolved.manifest.feature.manage_workdir.enabled);
|
||||||
assert!(!resolved.manifest.feature.plugins.enabled);
|
|
||||||
assert!(resolved.manifest.plugins.is_empty());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+271
-69
@@ -3,16 +3,17 @@
|
|||||||
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
|
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
|
||||||
//! rule `target` must already be an absolute path — per-layer path
|
//! rule `target` must already be an absolute path — per-layer path
|
||||||
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
|
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
|
||||||
//! All rule `target` paths inside the [`Scope`] are canonicalised (where
|
//! All rule targets retain both their lexically normalized logical identity and
|
||||||
//! possible) so access checks are pure path comparisons.
|
//! their provider-resolved identity. Allow rules select one identity explicitly;
|
||||||
|
//! deny rules always inspect both so aliases cannot bypass a restriction.
|
||||||
|
|
||||||
use std::ffi::OsString;
|
use std::ffi::OsString;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use arc_swap::{ArcSwap, Guard};
|
use arc_swap::{ArcSwap, Guard};
|
||||||
|
|
||||||
use crate::{Permission, ScopeConfig, ScopeRule};
|
use crate::{Permission, ScopeConfig, ScopeRule, SymlinkPolicy};
|
||||||
|
|
||||||
/// Parsed, pwd-resolved set of allow/deny rules for a Worker.
|
/// Parsed, pwd-resolved set of allow/deny rules for a Worker.
|
||||||
///
|
///
|
||||||
@@ -26,10 +27,13 @@ pub struct Scope {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
struct ResolvedRule {
|
struct ResolvedRule {
|
||||||
/// Absolute, canonicalized-or-normalized target directory/file.
|
/// Absolute, lexically normalized target as presented through the Workdir.
|
||||||
target: PathBuf,
|
logical_target: PathBuf,
|
||||||
|
/// Absolute target after provider-side symbolic-link resolution.
|
||||||
|
resolved_target: PathBuf,
|
||||||
permission: Permission,
|
permission: Permission,
|
||||||
recursive: bool,
|
recursive: bool,
|
||||||
|
symlink_policy: SymlinkPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parsed filesystem authority this Worker may pass to spawned children.
|
/// Parsed filesystem authority this Worker may pass to spawned children.
|
||||||
@@ -98,18 +102,46 @@ fn permission_denies_requested(denied: Permission, requested: Permission) -> boo
|
|||||||
|
|
||||||
fn rule_covers(available: &ResolvedRule, requested: &ResolvedRule) -> bool {
|
fn rule_covers(available: &ResolvedRule, requested: &ResolvedRule) -> bool {
|
||||||
permission_covers(available.permission, requested.permission)
|
permission_covers(available.permission, requested.permission)
|
||||||
&& rule_path_set_contains(available, requested)
|
&& available.symlink_policy >= requested.symlink_policy
|
||||||
|
&& rule_path_set_contains(
|
||||||
|
available,
|
||||||
|
requested,
|
||||||
|
match available.symlink_policy {
|
||||||
|
SymlinkPolicy::Resolved => RuleIdentity::Resolved,
|
||||||
|
SymlinkPolicy::Logical => RuleIdentity::Logical,
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn denial_overlaps_requested(deny: &ResolvedRule, requested: &ResolvedRule) -> bool {
|
fn denial_overlaps_requested(deny: &ResolvedRule, requested: &ResolvedRule) -> bool {
|
||||||
permission_denies_requested(deny.permission, requested.permission)
|
permission_denies_requested(deny.permission, requested.permission)
|
||||||
&& rule_path_sets_overlap(deny, requested)
|
&& (rule_path_sets_overlap(deny, requested, RuleIdentity::Logical)
|
||||||
|
|| rule_path_sets_overlap(deny, requested, RuleIdentity::Resolved))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rule_path_set_contains(available: &ResolvedRule, requested: &ResolvedRule) -> bool {
|
#[derive(Clone, Copy)]
|
||||||
|
enum RuleIdentity {
|
||||||
|
Logical,
|
||||||
|
Resolved,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rule_target(rule: &ResolvedRule, identity: RuleIdentity) -> &Path {
|
||||||
|
match identity {
|
||||||
|
RuleIdentity::Logical => &rule.logical_target,
|
||||||
|
RuleIdentity::Resolved => &rule.resolved_target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rule_path_set_contains(
|
||||||
|
available: &ResolvedRule,
|
||||||
|
requested: &ResolvedRule,
|
||||||
|
identity: RuleIdentity,
|
||||||
|
) -> bool {
|
||||||
|
let available_target = rule_target(available, identity);
|
||||||
|
let requested_target = rule_target(requested, identity);
|
||||||
match (available.recursive, requested.recursive) {
|
match (available.recursive, requested.recursive) {
|
||||||
// A recursive grant contains every possible requested path below its target.
|
// A recursive grant contains every possible requested path below its target.
|
||||||
(true, _) => requested.target.starts_with(&available.target),
|
(true, _) => requested_target.starts_with(available_target),
|
||||||
// A non-recursive grant contains only the target and its direct children;
|
// A non-recursive grant contains only the target and its direct children;
|
||||||
// a recursive request always includes descendants beyond that finite-depth
|
// a recursive request always includes descendants beyond that finite-depth
|
||||||
// set.
|
// set.
|
||||||
@@ -117,36 +149,42 @@ fn rule_path_set_contains(available: &ResolvedRule, requested: &ResolvedRule) ->
|
|||||||
// Two non-recursive rules have the same finite-depth set only when their
|
// Two non-recursive rules have the same finite-depth set only when their
|
||||||
// target is identical. A request rooted at a direct child would also grant
|
// target is identical. A request rooted at a direct child would also grant
|
||||||
// that child's children, which are grandchildren of `available.target`.
|
// that child's children, which are grandchildren of `available.target`.
|
||||||
(false, false) => requested.target == available.target,
|
(false, false) => requested_target == available_target,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rule_path_sets_overlap(left: &ResolvedRule, right: &ResolvedRule) -> bool {
|
fn rule_path_sets_overlap(
|
||||||
|
left: &ResolvedRule,
|
||||||
|
right: &ResolvedRule,
|
||||||
|
identity: RuleIdentity,
|
||||||
|
) -> bool {
|
||||||
|
let left_target = rule_target(left, identity);
|
||||||
|
let right_target = rule_target(right, identity);
|
||||||
match (left.recursive, right.recursive) {
|
match (left.recursive, right.recursive) {
|
||||||
(true, true) => {
|
(true, true) => {
|
||||||
left.target.starts_with(&right.target) || right.target.starts_with(&left.target)
|
left_target.starts_with(right_target) || right_target.starts_with(left_target)
|
||||||
}
|
}
|
||||||
(true, false) => recursive_and_non_recursive_sets_overlap(left, right),
|
(true, false) => recursive_and_non_recursive_sets_overlap(left_target, right_target),
|
||||||
(false, true) => recursive_and_non_recursive_sets_overlap(right, left),
|
(false, true) => recursive_and_non_recursive_sets_overlap(right_target, left_target),
|
||||||
(false, false) => {
|
(false, false) => {
|
||||||
left.target == right.target
|
left_target == right_target
|
||||||
|| direct_child(&left.target, &right.target)
|
|| direct_child(left_target, right_target)
|
||||||
|| direct_child(&right.target, &left.target)
|
|| direct_child(right_target, left_target)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn recursive_and_non_recursive_sets_overlap(
|
fn recursive_and_non_recursive_sets_overlap(
|
||||||
recursive: &ResolvedRule,
|
recursive_target: &Path,
|
||||||
non_recursive: &ResolvedRule,
|
non_recursive_target: &Path,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
// The non-recursive set is `{target} + direct children`. It overlaps a
|
// The non-recursive set is `{target} + direct children`. It overlaps a
|
||||||
// recursive subtree when either the non-recursive target is inside that
|
// recursive subtree when either the non-recursive target is inside that
|
||||||
// subtree, or the recursive subtree begins at the non-recursive target or
|
// subtree, or the recursive subtree begins at the non-recursive target or
|
||||||
// one of its direct children.
|
// one of its direct children.
|
||||||
non_recursive.target.starts_with(&recursive.target)
|
non_recursive_target.starts_with(recursive_target)
|
||||||
|| recursive.target == non_recursive.target
|
|| recursive_target == non_recursive_target
|
||||||
|| direct_child(&recursive.target, &non_recursive.target)
|
|| direct_child(recursive_target, non_recursive_target)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn direct_child(child: &Path, parent: &Path) -> bool {
|
fn direct_child(child: &Path, parent: &Path) -> bool {
|
||||||
@@ -201,23 +239,35 @@ impl Scope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience constructor for tests and simple setups: a single
|
/// Convenience constructor for tests and simple setups: a single
|
||||||
/// recursive `allow(Write)` rule rooted at `root`.
|
/// recursive `allow(Write)` rule rooted at `root` with the default
|
||||||
|
/// resolved-target symlink policy.
|
||||||
pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> {
|
pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> {
|
||||||
let root = root.as_ref().canonicalize()?;
|
let root = normalize_path(root.as_ref()).ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"scope root must be an absolute path without root traversal",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let resolved_root = resolve_path(&root)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
allow: vec![ResolvedRule {
|
allow: vec![ResolvedRule {
|
||||||
target: root,
|
logical_target: root,
|
||||||
|
resolved_target: resolved_root,
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: SymlinkPolicy::Resolved,
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve one rule target with the same symlink and missing-tail semantics
|
/// Return one rule target in the identity selected by its symlink policy.
|
||||||
/// used by scope matching.
|
|
||||||
pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> {
|
pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> {
|
||||||
Ok(resolve_rule(rule)?.target)
|
let rule = resolve_rule(rule)?;
|
||||||
|
Ok(match rule.symlink_policy {
|
||||||
|
SymlinkPolicy::Resolved => rule.resolved_target,
|
||||||
|
SymlinkPolicy::Logical => rule.logical_target,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return whether this effective scope fully contains a requested rule.
|
/// Return whether this effective scope fully contains a requested rule.
|
||||||
@@ -244,10 +294,23 @@ impl Scope {
|
|||||||
/// Returns `None` when `path` is outside every allow rule, or when
|
/// Returns `None` when `path` is outside every allow rule, or when
|
||||||
/// deny rules have knocked it below `Read`.
|
/// deny rules have knocked it below `Read`.
|
||||||
pub fn permission_at(&self, path: &Path) -> Option<Permission> {
|
pub fn permission_at(&self, path: &Path) -> Option<Permission> {
|
||||||
let resolved = resolve_path(path)?;
|
let logical = normalize_path(path)?;
|
||||||
|
let resolved = resolve_path(&logical).ok()?;
|
||||||
|
self.permission_at_paths(&logical, &resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Effective permission for a path whose logical and provider-resolved
|
||||||
|
/// identities were obtained inside the filesystem provider boundary.
|
||||||
|
pub fn permission_at_paths(&self, logical: &Path, resolved: &Path) -> Option<Permission> {
|
||||||
|
let logical = normalize_path(logical)?;
|
||||||
|
let resolved = normalize_path(resolved)?;
|
||||||
let mut effective: Option<Permission> = None;
|
let mut effective: Option<Permission> = None;
|
||||||
for rule in &self.allow {
|
for rule in &self.allow {
|
||||||
if rule.matches(&resolved) {
|
let candidate = match rule.symlink_policy {
|
||||||
|
SymlinkPolicy::Resolved => &resolved,
|
||||||
|
SymlinkPolicy::Logical => &logical,
|
||||||
|
};
|
||||||
|
if rule.matches(candidate, rule.symlink_policy) {
|
||||||
effective = match effective {
|
effective = match effective {
|
||||||
None => Some(rule.permission),
|
None => Some(rule.permission),
|
||||||
Some(cur) => Some(cur.max(rule.permission)),
|
Some(cur) => Some(cur.max(rule.permission)),
|
||||||
@@ -256,11 +319,13 @@ impl Scope {
|
|||||||
}
|
}
|
||||||
let mut effective = effective?;
|
let mut effective = effective?;
|
||||||
|
|
||||||
// Deny: min(min_deny) dictates the cap. Effective level is capped
|
// Deny rules always inspect both identities. This prevents a logical
|
||||||
// strictly below that value, so deny(read) wipes access entirely.
|
// alias or a second symlink to the same target from bypassing a deny.
|
||||||
let mut min_deny: Option<Permission> = None;
|
let mut min_deny: Option<Permission> = None;
|
||||||
for rule in &self.deny {
|
for rule in &self.deny {
|
||||||
if rule.matches(&resolved) {
|
if rule.matches(&logical, SymlinkPolicy::Logical)
|
||||||
|
|| rule.matches(&resolved, SymlinkPolicy::Resolved)
|
||||||
|
{
|
||||||
min_deny = match min_deny {
|
min_deny = match min_deny {
|
||||||
None => Some(rule.permission),
|
None => Some(rule.permission),
|
||||||
Some(cur) => Some(cur.min(rule.permission)),
|
Some(cur) => Some(cur.min(rule.permission)),
|
||||||
@@ -293,7 +358,7 @@ impl Scope {
|
|||||||
/// rule, preserving declaration order. Does not account for deny
|
/// rule, preserving declaration order. Does not account for deny
|
||||||
/// rules, which only cap effective permission at query time.
|
/// rules, which only cap effective permission at query time.
|
||||||
pub fn readable_paths(&self) -> impl Iterator<Item = &Path> {
|
pub fn readable_paths(&self) -> impl Iterator<Item = &Path> {
|
||||||
self.allow.iter().map(|r| r.target.as_path())
|
self.allow.iter().map(|r| r.logical_target.as_path())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Allow rules with their targets resolved to absolute paths.
|
/// Allow rules with their targets resolved to absolute paths.
|
||||||
@@ -305,9 +370,10 @@ impl Scope {
|
|||||||
self.allow
|
self.allow
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| ScopeRule {
|
.map(|r| ScopeRule {
|
||||||
target: r.target.clone(),
|
target: r.logical_target.clone(),
|
||||||
permission: r.permission,
|
permission: r.permission,
|
||||||
recursive: r.recursive,
|
recursive: r.recursive,
|
||||||
|
symlink_policy: r.symlink_policy,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -322,9 +388,10 @@ impl Scope {
|
|||||||
self.deny
|
self.deny
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| ScopeRule {
|
.map(|r| ScopeRule {
|
||||||
target: r.target.clone(),
|
target: r.logical_target.clone(),
|
||||||
permission: r.permission,
|
permission: r.permission,
|
||||||
recursive: r.recursive,
|
recursive: r.recursive,
|
||||||
|
symlink_policy: r.symlink_policy,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -335,7 +402,7 @@ impl Scope {
|
|||||||
self.allow
|
self.allow
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|r| r.permission == Permission::Write)
|
.filter(|r| r.permission == Permission::Write)
|
||||||
.map(|r| r.target.as_path())
|
.map(|r| r.logical_target.as_path())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a new [`Scope`] equal to `self` with `extra_allow` appended
|
/// Build a new [`Scope`] equal to `self` with `extra_allow` appended
|
||||||
@@ -412,7 +479,10 @@ impl Scope {
|
|||||||
pub fn summary(&self) -> String {
|
pub fn summary(&self) -> String {
|
||||||
fn push_rule(out: &mut String, rule: &ResolvedRule) {
|
fn push_rule(out: &mut String, rule: &ResolvedRule) {
|
||||||
out.push_str(" - ");
|
out.push_str(" - ");
|
||||||
out.push_str(&rule.target.display().to_string());
|
out.push_str(&rule.logical_target.display().to_string());
|
||||||
|
if rule.symlink_policy == SymlinkPolicy::Logical {
|
||||||
|
out.push_str(" [logical-symlinks]");
|
||||||
|
}
|
||||||
if !rule.recursive {
|
if !rule.recursive {
|
||||||
out.push_str(" [non-recursive]");
|
out.push_str(" [non-recursive]");
|
||||||
}
|
}
|
||||||
@@ -510,11 +580,15 @@ impl SharedScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ResolvedRule {
|
impl ResolvedRule {
|
||||||
fn matches(&self, path: &Path) -> bool {
|
fn matches(&self, path: &Path, identity: SymlinkPolicy) -> bool {
|
||||||
|
let target = match identity {
|
||||||
|
SymlinkPolicy::Resolved => &self.resolved_target,
|
||||||
|
SymlinkPolicy::Logical => &self.logical_target,
|
||||||
|
};
|
||||||
if self.recursive {
|
if self.recursive {
|
||||||
path.starts_with(&self.target)
|
path.starts_with(target)
|
||||||
} else {
|
} else {
|
||||||
path == self.target || path.parent() == Some(self.target.as_path())
|
path == target || path.parent() == Some(target.as_path())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -523,48 +597,84 @@ fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
|
|||||||
if !rule.target.is_absolute() {
|
if !rule.target.is_absolute() {
|
||||||
return Err(ScopeError::RelativeTarget(rule.target.clone()));
|
return Err(ScopeError::RelativeTarget(rule.target.clone()));
|
||||||
}
|
}
|
||||||
let target = resolve_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
|
let logical_target = normalize_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
|
||||||
path: rule.target.clone(),
|
path: rule.target.clone(),
|
||||||
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"),
|
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"),
|
||||||
})?;
|
})?;
|
||||||
|
let resolved_target =
|
||||||
|
resolve_path(&logical_target).map_err(|source| ScopeError::ResolveTarget {
|
||||||
|
path: rule.target.clone(),
|
||||||
|
source,
|
||||||
|
})?;
|
||||||
Ok(ResolvedRule {
|
Ok(ResolvedRule {
|
||||||
target,
|
logical_target,
|
||||||
|
resolved_target,
|
||||||
permission: rule.permission,
|
permission: rule.permission,
|
||||||
recursive: rule.recursive,
|
recursive: rule.recursive,
|
||||||
|
symlink_policy: rule.symlink_policy,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert `path` to an absolute form suitable for prefix comparison.
|
/// Resolve every existing path component while retaining a missing final tail.
|
||||||
///
|
/// A dangling symlink is rejected rather than treated as an ordinary missing
|
||||||
/// Tries `canonicalize` on the full path first (resolves symlinks). If
|
/// component because its resolved authority cannot be established.
|
||||||
/// the path doesn't exist yet, climbs to the closest existing ancestor,
|
fn resolve_path(path: &Path) -> std::io::Result<PathBuf> {
|
||||||
/// canonicalizes it, then rejoins the missing tail. Returns `None` for
|
let mut cursor = path;
|
||||||
/// relative inputs that have no existing ancestor to anchor against.
|
let mut missing = Vec::<OsString>::new();
|
||||||
fn resolve_path(path: &Path) -> Option<PathBuf> {
|
loop {
|
||||||
|
match std::fs::canonicalize(cursor) {
|
||||||
|
Ok(mut resolved) => {
|
||||||
|
for component in missing.iter().rev() {
|
||||||
|
resolved.push(component);
|
||||||
|
}
|
||||||
|
return normalize_path(&resolved).ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"resolved target is not an absolute normalized path",
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
||||||
|
if std::fs::symlink_metadata(cursor)
|
||||||
|
.is_ok_and(|metadata| metadata.file_type().is_symlink())
|
||||||
|
{
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
let name = cursor.file_name().ok_or(error)?;
|
||||||
|
missing.push(name.to_os_string());
|
||||||
|
cursor = cursor.parent().ok_or_else(|| {
|
||||||
|
std::io::Error::new(
|
||||||
|
std::io::ErrorKind::NotFound,
|
||||||
|
"scope target has no existing ancestor",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(error) => return Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize an absolute path for lexical scope comparison without consulting
|
||||||
|
/// filesystem metadata or resolving symbolic links.
|
||||||
|
fn normalize_path(path: &Path) -> Option<PathBuf> {
|
||||||
if !path.is_absolute() {
|
if !path.is_absolute() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if let Ok(canonical) = path.canonicalize() {
|
let mut normalized = PathBuf::new();
|
||||||
return Some(canonical);
|
for component in path.components() {
|
||||||
}
|
match component {
|
||||||
let mut tail: Vec<OsString> = Vec::new();
|
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
|
||||||
let mut cur = path.to_path_buf();
|
Component::RootDir => normalized.push(component.as_os_str()),
|
||||||
loop {
|
Component::CurDir => {}
|
||||||
if let Ok(canonical) = cur.canonicalize() {
|
Component::ParentDir => {
|
||||||
let mut out = canonical;
|
if !normalized.pop() {
|
||||||
for segment in tail.iter().rev() {
|
return None;
|
||||||
out.push(segment);
|
}
|
||||||
}
|
}
|
||||||
return Some(out);
|
Component::Normal(part) => normalized.push(part),
|
||||||
}
|
}
|
||||||
let name = cur.file_name()?.to_os_string();
|
|
||||||
tail.push(name);
|
|
||||||
let parent = cur.parent()?.to_path_buf();
|
|
||||||
if parent == cur {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
cur = parent;
|
|
||||||
}
|
}
|
||||||
|
normalized.is_absolute().then_some(normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -577,6 +687,7 @@ mod tests {
|
|||||||
target: target.to_path_buf(),
|
target: target.to_path_buf(),
|
||||||
permission,
|
permission,
|
||||||
recursive,
|
recursive,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -691,6 +802,7 @@ mod tests {
|
|||||||
target: dir.path().to_path_buf(),
|
target: dir.path().to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: false,
|
recursive: false,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -790,6 +902,7 @@ mod tests {
|
|||||||
target: PathBuf::from("relative/path"),
|
target: PathBuf::from("relative/path"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -805,6 +918,88 @@ mod tests {
|
|||||||
assert!(!scope.is_readable(&traversal));
|
assert!(!scope.is_readable(&traversal));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn scope_defaults_to_resolved_symlink_authority_and_logical_is_explicit() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let outside = TempDir::new().unwrap();
|
||||||
|
std::fs::write(outside.path().join("outside.txt"), "visible through link").unwrap();
|
||||||
|
symlink(outside.path(), dir.path().join("external")).unwrap();
|
||||||
|
|
||||||
|
let resolved = Scope::writable(dir.path()).unwrap();
|
||||||
|
assert!(!resolved.is_readable(&dir.path().join("external/outside.txt")));
|
||||||
|
assert!(!resolved.is_writable(&dir.path().join("external/new.txt")));
|
||||||
|
|
||||||
|
let logical = Scope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![ScopeRule {
|
||||||
|
target: dir.path().to_path_buf(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
symlink_policy: SymlinkPolicy::Logical,
|
||||||
|
}],
|
||||||
|
deny: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(logical.is_readable(&dir.path().join("external/outside.txt")));
|
||||||
|
assert!(logical.is_writable(&dir.path().join("external/new.txt")));
|
||||||
|
assert!(!logical.is_readable(&outside.path().join("outside.txt")));
|
||||||
|
assert!(!logical.is_writable(&outside.path().join("new.txt")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn deny_rules_match_both_logical_alias_and_resolved_target() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let secret = root.path().join("secret");
|
||||||
|
std::fs::create_dir(&secret).unwrap();
|
||||||
|
std::fs::write(secret.join("key"), "hidden").unwrap();
|
||||||
|
symlink(&secret, root.path().join("alias")).unwrap();
|
||||||
|
let scope = Scope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![ScopeRule {
|
||||||
|
target: root.path().to_path_buf(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
symlink_policy: SymlinkPolicy::Logical,
|
||||||
|
}],
|
||||||
|
deny: vec![ScopeRule {
|
||||||
|
target: secret,
|
||||||
|
permission: Permission::Read,
|
||||||
|
recursive: true,
|
||||||
|
symlink_policy: SymlinkPolicy::Logical,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!scope.is_readable(&root.path().join("alias/key")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn delegation_symlink_policy_is_monotonically_attenuated() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let mut parent_rule = allow_rule(root.path(), Permission::Write);
|
||||||
|
parent_rule.symlink_policy = SymlinkPolicy::Logical;
|
||||||
|
let logical_parent = DelegationScope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![parent_rule],
|
||||||
|
deny: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let resolved_child = allow_rule(&root.path().join("child"), Permission::Read);
|
||||||
|
assert!(logical_parent.allows_rule(&resolved_child).unwrap());
|
||||||
|
|
||||||
|
let resolved_parent = DelegationScope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![allow_rule(root.path(), Permission::Write)],
|
||||||
|
deny: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let mut logical_child = resolved_child;
|
||||||
|
logical_child.symlink_policy = SymlinkPolicy::Logical;
|
||||||
|
assert!(!resolved_parent.allows_rule(&logical_child).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn summary_lists_readable_and_writable() {
|
fn summary_lists_readable_and_writable() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -851,11 +1046,13 @@ mod tests {
|
|||||||
target: docs.clone(),
|
target: docs.clone(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: false,
|
recursive: false,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
},
|
},
|
||||||
ScopeRule {
|
ScopeRule {
|
||||||
target: dir.path().to_path_buf(),
|
target: dir.path().to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
@@ -914,6 +1111,7 @@ mod tests {
|
|||||||
target: extra.path().to_path_buf(),
|
target: extra.path().to_path_buf(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}])
|
}])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(extended.is_readable(&extra.path().join("x")));
|
assert!(extended.is_readable(&extra.path().join("x")));
|
||||||
@@ -931,6 +1129,7 @@ mod tests {
|
|||||||
target: sub.clone(),
|
target: sub.clone(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}])
|
}])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let f = sub.join("a.txt");
|
let f = sub.join("a.txt");
|
||||||
@@ -950,6 +1149,7 @@ mod tests {
|
|||||||
target: sub.clone(),
|
target: sub.clone(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
};
|
};
|
||||||
let base = Scope::writable(dir.path())
|
let base = Scope::writable(dir.path())
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -1003,6 +1203,7 @@ mod tests {
|
|||||||
target: sub.clone(),
|
target: sub.clone(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1021,6 +1222,7 @@ mod tests {
|
|||||||
target: extra.path().to_path_buf(),
|
target: extra.path().to_path_buf(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ fn deny_write(target: &Path) -> ScopeRule {
|
|||||||
target: target.to_path_buf(),
|
target: target.to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+142
-137
@@ -85,24 +85,19 @@ impl AuthenticatedInputSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Immutable identity and revision fence for one state-changing Worker command.
|
/// Caller-owned identity for one state-changing Worker command.
|
||||||
|
///
|
||||||
|
/// A controller accepts command ids in strictly increasing order. Exact retries
|
||||||
|
/// of an accepted id must retain the same command kind.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct WorkerCommandEnvelope {
|
pub struct WorkerCommandEnvelope {
|
||||||
/// Caller-owned sequence. A controller accepts command ids in strictly
|
|
||||||
/// increasing order for one execution generation.
|
|
||||||
pub command_id: u64,
|
pub command_id: u64,
|
||||||
pub expected_execution_generation: u64,
|
|
||||||
pub expected_worker_state_revision: u64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerCommandEnvelope {
|
impl WorkerCommandEnvelope {
|
||||||
pub fn for_snapshot(command_id: u64, snapshot: &WorkerStateSnapshot) -> Self {
|
pub fn new(command_id: u64) -> Self {
|
||||||
Self {
|
Self { command_id }
|
||||||
command_id,
|
|
||||||
expected_execution_generation: snapshot.execution_generation,
|
|
||||||
expected_worker_state_revision: snapshot.revision,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,8 +117,6 @@ pub enum WorkerCommandKind {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerCommandDisposition {
|
pub enum WorkerCommandDisposition {
|
||||||
Accepted,
|
Accepted,
|
||||||
StaleExecutionGeneration,
|
|
||||||
StaleWorkerStateRevision,
|
|
||||||
StaleCommandId,
|
StaleCommandId,
|
||||||
Conflict,
|
Conflict,
|
||||||
InvalidState,
|
InvalidState,
|
||||||
@@ -175,18 +168,14 @@ pub enum WorkerMaintenanceState {
|
|||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct WorkerStateSnapshot {
|
pub struct WorkerStateSnapshot {
|
||||||
pub execution_generation: u64,
|
/// Highest lifecycle command id observed by this controller instance.
|
||||||
pub revision: u64,
|
|
||||||
/// Highest lifecycle command id observed by this controller generation.
|
|
||||||
pub last_command_id: u64,
|
pub last_command_id: u64,
|
||||||
pub state: WorkerState,
|
pub state: WorkerState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerStateSnapshot {
|
impl WorkerStateSnapshot {
|
||||||
pub fn initial(execution_generation: u64) -> Self {
|
pub fn initial() -> Self {
|
||||||
Self {
|
Self {
|
||||||
execution_generation,
|
|
||||||
revision: 0,
|
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
state: WorkerState::Idle,
|
state: WorkerState::Idle,
|
||||||
}
|
}
|
||||||
@@ -204,53 +193,6 @@ impl WorkerStateSnapshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum WorkerStateSnapshotApply {
|
|
||||||
Applied,
|
|
||||||
Duplicate,
|
|
||||||
Stale,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub struct WorkerStateSnapshotConflict {
|
|
||||||
pub execution_generation: u64,
|
|
||||||
pub revision: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for WorkerStateSnapshotConflict {
|
|
||||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(
|
|
||||||
formatter,
|
|
||||||
"conflicting worker state snapshots at generation {} revision {}",
|
|
||||||
self.execution_generation, self.revision
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for WorkerStateSnapshotConflict {}
|
|
||||||
|
|
||||||
pub fn apply_worker_state_snapshot(
|
|
||||||
current: &mut WorkerStateSnapshot,
|
|
||||||
incoming: &WorkerStateSnapshot,
|
|
||||||
) -> Result<WorkerStateSnapshotApply, WorkerStateSnapshotConflict> {
|
|
||||||
use std::cmp::Ordering;
|
|
||||||
|
|
||||||
let ordering = (incoming.execution_generation, incoming.revision)
|
|
||||||
.cmp(&(current.execution_generation, current.revision));
|
|
||||||
match ordering {
|
|
||||||
Ordering::Greater => {
|
|
||||||
*current = incoming.clone();
|
|
||||||
Ok(WorkerStateSnapshotApply::Applied)
|
|
||||||
}
|
|
||||||
Ordering::Less => Ok(WorkerStateSnapshotApply::Stale),
|
|
||||||
Ordering::Equal if incoming == current => Ok(WorkerStateSnapshotApply::Duplicate),
|
|
||||||
Ordering::Equal => Err(WorkerStateSnapshotConflict {
|
|
||||||
execution_generation: incoming.execution_generation,
|
|
||||||
revision: incoming.revision,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<WorkerStatus> for WorkerStateSnapshot {
|
impl From<WorkerStatus> for WorkerStateSnapshot {
|
||||||
fn from(status: WorkerStatus) -> Self {
|
fn from(status: WorkerStatus) -> Self {
|
||||||
let state = match status {
|
let state = match status {
|
||||||
@@ -261,8 +203,6 @@ impl From<WorkerStatus> for WorkerStateSnapshot {
|
|||||||
WorkerStatus::Paused => WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)),
|
WorkerStatus::Paused => WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused)),
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
execution_generation: 1,
|
|
||||||
revision: 0,
|
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
state,
|
state,
|
||||||
}
|
}
|
||||||
@@ -1228,8 +1168,12 @@ pub enum Event {
|
|||||||
/// This is not part of LLM history or prompt context; clients may display it
|
/// This is not part of LLM history or prompt context; clients may display it
|
||||||
/// briefly as operational status.
|
/// briefly as operational status.
|
||||||
MemoryWorker(MemoryWorkerEvent),
|
MemoryWorker(MemoryWorkerEvent),
|
||||||
/// Worker has started compacting the current session, or bound the run to its
|
/// Runtime-only compaction progress. `None` clears the current status.
|
||||||
/// observable Internal Worker. Revisions upsert one stable lifecycle item.
|
/// This never enters Session history and carries no operation or Segment identity.
|
||||||
|
CompactionProgress {
|
||||||
|
compaction: Option<InFlightCompaction>,
|
||||||
|
},
|
||||||
|
/// Legacy compaction lifecycle event retained for wire read compatibility.
|
||||||
CompactStart {
|
CompactStart {
|
||||||
lifecycle: CompactionLifecycle,
|
lifecycle: CompactionLifecycle,
|
||||||
},
|
},
|
||||||
@@ -1416,6 +1360,32 @@ pub enum CommandEvent {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CompactionPhase {
|
||||||
|
Preparing,
|
||||||
|
Summarizing,
|
||||||
|
Committing,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum CompactionTrigger {
|
||||||
|
Manual,
|
||||||
|
PreRun,
|
||||||
|
RequestThreshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
pub struct InFlightCompaction {
|
||||||
|
pub phase: CompactionPhase,
|
||||||
|
pub started_at_ms: u64,
|
||||||
|
pub trigger: CompactionTrigger,
|
||||||
|
}
|
||||||
|
|
||||||
/// Unfinished model output and active command state included in
|
/// Unfinished model output and active command state included in
|
||||||
/// `Event::Snapshot` for clients that attach while work is still streaming.
|
/// `Event::Snapshot` for clients that attach while work is still streaming.
|
||||||
///
|
///
|
||||||
@@ -1430,11 +1400,17 @@ pub struct InFlightSnapshot {
|
|||||||
pub blocks: Vec<InFlightBlock>,
|
pub blocks: Vec<InFlightBlock>,
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub commands: Vec<CommandSnapshot>,
|
pub commands: Vec<CommandSnapshot>,
|
||||||
|
/// The currently running compaction, if any.
|
||||||
|
///
|
||||||
|
/// This is lifecycle progress only. Candidate history and the staged
|
||||||
|
/// Segment remain private until the Segment is activated atomically.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub compaction: Option<InFlightCompaction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl InFlightSnapshot {
|
impl InFlightSnapshot {
|
||||||
pub fn is_empty(&self) -> bool {
|
pub fn is_empty(&self) -> bool {
|
||||||
self.blocks.is_empty() && self.commands.is_empty()
|
self.blocks.is_empty() && self.commands.is_empty() && self.compaction.is_none()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1599,12 +1575,30 @@ pub struct ScopeRule {
|
|||||||
/// direct children. Defaults to `true`.
|
/// direct children. Defaults to `true`.
|
||||||
#[serde(default = "default_recursive")]
|
#[serde(default = "default_recursive")]
|
||||||
pub recursive: bool,
|
pub recursive: bool,
|
||||||
|
/// Which path identity an allow rule uses when symbolic links are
|
||||||
|
/// encountered. Deny rules always inspect both identities.
|
||||||
|
#[serde(default)]
|
||||||
|
pub symlink_policy: SymlinkPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_recursive() -> bool {
|
fn default_recursive() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Symbolic-link identity used by one filesystem allow rule.
|
||||||
|
///
|
||||||
|
/// `Resolved` is the least authority and the default: access is matched
|
||||||
|
/// against the provider-resolved target. `Logical` intentionally grants the
|
||||||
|
/// path as presented through the Workdir, even when it aliases another target.
|
||||||
|
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum SymlinkPolicy {
|
||||||
|
#[default]
|
||||||
|
Resolved,
|
||||||
|
Logical,
|
||||||
|
}
|
||||||
|
|
||||||
/// Permission lattice used by [`ScopeRule`].
|
/// Permission lattice used by [`ScopeRule`].
|
||||||
///
|
///
|
||||||
/// The derived `Ord` instance follows declaration order, so
|
/// The derived `Ord` instance follows declaration order, so
|
||||||
@@ -1624,55 +1618,43 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_state_snapshot_apply_is_monotonic_and_detects_conflicts() {
|
fn scope_rule_defaults_to_resolved_symlink_policy() {
|
||||||
let mut current = WorkerStateSnapshot::initial(4);
|
let rule: ScopeRule = serde_json::from_value(serde_json::json!({
|
||||||
let mut newer = current.clone();
|
"target": "/workspace",
|
||||||
newer.revision = 1;
|
"permission": "read"
|
||||||
newer.state = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running));
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(rule.recursive);
|
||||||
|
assert_eq!(rule.symlink_policy, SymlinkPolicy::Resolved);
|
||||||
|
|
||||||
assert_eq!(
|
let logical: ScopeRule = serde_json::from_value(serde_json::json!({
|
||||||
apply_worker_state_snapshot(&mut current, &newer),
|
"target": "/workspace",
|
||||||
Ok(WorkerStateSnapshotApply::Applied)
|
"permission": "read",
|
||||||
);
|
"symlink_policy": "logical"
|
||||||
assert_eq!(
|
}))
|
||||||
apply_worker_state_snapshot(&mut current, &newer),
|
.unwrap();
|
||||||
Ok(WorkerStateSnapshotApply::Duplicate)
|
assert_eq!(logical.symlink_policy, SymlinkPolicy::Logical);
|
||||||
);
|
}
|
||||||
|
|
||||||
let stale_revision = WorkerStateSnapshot::initial(4);
|
#[test]
|
||||||
assert_eq!(
|
fn worker_state_snapshot_wire_shape_has_one_authoritative_state() {
|
||||||
apply_worker_state_snapshot(&mut current, &stale_revision),
|
let snapshot = WorkerStateSnapshot {
|
||||||
Ok(WorkerStateSnapshotApply::Stale)
|
last_command_id: 7,
|
||||||
);
|
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
||||||
let stale_generation = WorkerStateSnapshot {
|
|
||||||
execution_generation: 3,
|
|
||||||
revision: u64::MAX,
|
|
||||||
..newer.clone()
|
|
||||||
};
|
};
|
||||||
|
let value = serde_json::to_value(&snapshot).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
apply_worker_state_snapshot(&mut current, &stale_generation),
|
value,
|
||||||
Ok(WorkerStateSnapshotApply::Stale)
|
serde_json::json!({
|
||||||
);
|
"last_command_id": 7,
|
||||||
|
"state": {
|
||||||
let conflicting = WorkerStateSnapshot {
|
"kind": "busy",
|
||||||
state: WorkerState::Idle,
|
"state": { "kind": "run", "state": "running" }
|
||||||
..newer.clone()
|
}
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
apply_worker_state_snapshot(&mut current, &conflicting),
|
|
||||||
Err(WorkerStateSnapshotConflict {
|
|
||||||
execution_generation: 4,
|
|
||||||
revision: 1,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
assert_eq!(current, newer);
|
assert!(value.get("execution_generation").is_none());
|
||||||
|
assert!(value.get("revision").is_none());
|
||||||
let next_generation = WorkerStateSnapshot::initial(5);
|
|
||||||
assert_eq!(
|
|
||||||
apply_worker_state_snapshot(&mut current, &next_generation),
|
|
||||||
Ok(WorkerStateSnapshotApply::Applied)
|
|
||||||
);
|
|
||||||
assert_eq!(current, next_generation);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1869,28 +1851,21 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lifecycle_methods_roundtrip_with_fences() {
|
fn lifecycle_methods_roundtrip_with_command_identity() {
|
||||||
for method in [
|
for method in [
|
||||||
Method::Pause {
|
Method::Pause {
|
||||||
command: WorkerCommandEnvelope {
|
command: WorkerCommandEnvelope { command_id: 11 },
|
||||||
command_id: 11,
|
|
||||||
expected_execution_generation: 4,
|
|
||||||
expected_worker_state_revision: 8,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
Method::Compact {
|
Method::Compact {
|
||||||
command: WorkerCommandEnvelope {
|
command: WorkerCommandEnvelope { command_id: 12 },
|
||||||
command_id: 12,
|
|
||||||
expected_execution_generation: 4,
|
|
||||||
expected_worker_state_revision: 9,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
] {
|
] {
|
||||||
let json = serde_json::to_string(&method).unwrap();
|
let json = serde_json::to_string(&method).unwrap();
|
||||||
|
assert!(!json.contains("expected_execution_generation"));
|
||||||
|
assert!(!json.contains("expected_worker_state_revision"));
|
||||||
let decoded: Method = serde_json::from_str(&json).unwrap();
|
let decoded: Method = serde_json::from_str(&json).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
Method::Pause { command } | Method::Compact { command } => {
|
Method::Pause { command } | Method::Compact { command } => {
|
||||||
assert_eq!(command.expected_execution_generation, 4);
|
|
||||||
assert!(command.command_id >= 11);
|
assert!(command.command_id >= 11);
|
||||||
}
|
}
|
||||||
other => panic!("unexpected lifecycle method: {other:?}"),
|
other => panic!("unexpected lifecycle method: {other:?}"),
|
||||||
@@ -2187,7 +2162,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_snapshot_in_flight_roundtrip_and_default() {
|
fn event_snapshot_in_flight_roundtrip_and_default() {
|
||||||
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"state":{"execution_generation":1,"revision":1,"last_command_id":0,"state":{"kind":"busy","state":{"kind":"run","state":"running"}}}}}"#;
|
let inbound = r#"{"event":"snapshot","data":{"session":{"entries":[]},"greeting":{"worker_name":"test","cwd":"/tmp","provider":"p","model":"m","scope_summary":"s","tools":[]},"state":{"last_command_id":0,"state":{"kind":"busy","state":{"kind":"run","state":"running"}}}}}"#;
|
||||||
let decoded: Event = serde_json::from_str(inbound).unwrap();
|
let decoded: Event = serde_json::from_str(inbound).unwrap();
|
||||||
match decoded {
|
match decoded {
|
||||||
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
|
Event::Snapshot { in_flight, .. } => assert!(in_flight.is_empty()),
|
||||||
@@ -2243,6 +2218,11 @@ mod tests {
|
|||||||
stderr: CommandStreamSlice::default(),
|
stderr: CommandStreamSlice::default(),
|
||||||
exit_code: None,
|
exit_code: None,
|
||||||
}],
|
}],
|
||||||
|
compaction: Some(InFlightCompaction {
|
||||||
|
phase: CompactionPhase::Summarizing,
|
||||||
|
started_at_ms: 99,
|
||||||
|
trigger: CompactionTrigger::Manual,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
};
|
};
|
||||||
@@ -2254,9 +2234,35 @@ mod tests {
|
|||||||
parsed["data"]["in_flight"]["blocks"][2]["state"],
|
parsed["data"]["in_flight"]["blocks"][2]["state"],
|
||||||
"streaming_args"
|
"streaming_args"
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed["data"]["in_flight"]["compaction"]["phase"],
|
||||||
|
"summarizing"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parsed["data"]["in_flight"]["compaction"]["trigger"],
|
||||||
|
"manual"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
parsed["data"]["in_flight"]["compaction"]
|
||||||
|
.as_object()
|
||||||
|
.is_some_and(|value| {
|
||||||
|
!value.contains_key("state")
|
||||||
|
&& !value.contains_key("compaction_id")
|
||||||
|
&& !value.contains_key("internal_worker")
|
||||||
|
&& !value.contains_key("summary")
|
||||||
|
&& !value.contains_key("new_segment_id")
|
||||||
|
}),
|
||||||
|
"in-flight compaction progress must not expose terminal or staged state"
|
||||||
|
);
|
||||||
|
|
||||||
match serde_json::from_str::<Event>(&json).unwrap() {
|
match serde_json::from_str::<Event>(&json).unwrap() {
|
||||||
Event::Snapshot { in_flight, .. } => assert_eq!(in_flight.blocks.len(), 3),
|
Event::Snapshot { in_flight, .. } => {
|
||||||
|
assert_eq!(in_flight.blocks.len(), 3);
|
||||||
|
assert_eq!(
|
||||||
|
in_flight.compaction.unwrap().phase,
|
||||||
|
CompactionPhase::Summarizing
|
||||||
|
);
|
||||||
|
}
|
||||||
other => panic!("expected Snapshot, got {other:?}"),
|
other => panic!("expected Snapshot, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2300,8 +2306,6 @@ mod tests {
|
|||||||
fn event_worker_state_format() {
|
fn event_worker_state_format() {
|
||||||
let event = Event::WorkerState {
|
let event = Event::WorkerState {
|
||||||
snapshot: WorkerStateSnapshot {
|
snapshot: WorkerStateSnapshot {
|
||||||
execution_generation: 7,
|
|
||||||
revision: 3,
|
|
||||||
last_command_id: 9,
|
last_command_id: 9,
|
||||||
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
||||||
},
|
},
|
||||||
@@ -2309,8 +2313,12 @@ mod tests {
|
|||||||
let json = serde_json::to_string(&event).unwrap();
|
let json = serde_json::to_string(&event).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert_eq!(parsed["event"], "worker_state");
|
assert_eq!(parsed["event"], "worker_state");
|
||||||
assert_eq!(parsed["data"]["snapshot"]["execution_generation"], 7);
|
assert!(
|
||||||
assert_eq!(parsed["data"]["snapshot"]["revision"], 3);
|
parsed["data"]["snapshot"]
|
||||||
|
.get("execution_generation")
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(parsed["data"]["snapshot"].get("revision").is_none());
|
||||||
assert_eq!(parsed["data"]["snapshot"]["state"]["kind"], "busy");
|
assert_eq!(parsed["data"]["snapshot"]["state"]["kind"], "busy");
|
||||||
|
|
||||||
let decoded: Event = serde_json::from_str(&json).unwrap();
|
let decoded: Event = serde_json::from_str(&json).unwrap();
|
||||||
@@ -2318,10 +2326,8 @@ mod tests {
|
|||||||
decoded,
|
decoded,
|
||||||
Event::WorkerState {
|
Event::WorkerState {
|
||||||
snapshot: WorkerStateSnapshot {
|
snapshot: WorkerStateSnapshot {
|
||||||
execution_generation: 7,
|
last_command_id: 9,
|
||||||
revision: 3,
|
|
||||||
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
state: WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running)),
|
||||||
..
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
@@ -2462,6 +2468,7 @@ mod tests {
|
|||||||
target: "/tmp/work".into(),
|
target: "/tmp/work".into(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
let json = serde_json::to_string(&method).unwrap();
|
let json = serde_json::to_string(&method).unwrap();
|
||||||
@@ -2780,8 +2787,6 @@ mod tests {
|
|||||||
"tools": []
|
"tools": []
|
||||||
},
|
},
|
||||||
"state": {
|
"state": {
|
||||||
"execution_generation": 1,
|
|
||||||
"revision": 0,
|
|
||||||
"last_command_id": 0,
|
"last_command_id": 0,
|
||||||
"state": { "kind": "idle" }
|
"state": { "kind": "idle" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,18 @@ use ts_rs::{Config, TS};
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream,
|
||||||
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompletionEntry,
|
CommandStreamSlice, CompactionLifecycle, CompactionLifecycleState, CompactionPhase,
|
||||||
CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, InFlightSnapshot,
|
CompactionTrigger, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock,
|
||||||
InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot,
|
InFlightCompaction, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind,
|
||||||
InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType,
|
InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method,
|
||||||
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
|
PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary,
|
||||||
RewindSummary, RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, SessionContentPart,
|
PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult,
|
||||||
SessionEntryProvenance, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
|
ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole,
|
||||||
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, ToolResultDisposition,
|
SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment,
|
||||||
TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
|
SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult,
|
||||||
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
|
UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement,
|
||||||
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
|
WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent,
|
||||||
WorkerStateSnapshot, WorkerStatus,
|
WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus,
|
||||||
subscription::{
|
subscription::{
|
||||||
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame,
|
||||||
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
SubscriptionFramePayload, SubscriptionId, SubscriptionRejectionCode, SubscriptionRequest,
|
||||||
@@ -64,6 +64,7 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<ToolResultDisposition>(&cfg, &mut output);
|
push_decl::<ToolResultDisposition>(&cfg, &mut output);
|
||||||
push_decl::<ErrorCode>(&cfg, &mut output);
|
push_decl::<ErrorCode>(&cfg, &mut output);
|
||||||
push_decl::<Permission>(&cfg, &mut output);
|
push_decl::<Permission>(&cfg, &mut output);
|
||||||
|
push_decl::<SymlinkPolicy>(&cfg, &mut output);
|
||||||
push_decl::<InFlightToolCallState>(&cfg, &mut output);
|
push_decl::<InFlightToolCallState>(&cfg, &mut output);
|
||||||
push_decl::<CommandStatus>(&cfg, &mut output);
|
push_decl::<CommandStatus>(&cfg, &mut output);
|
||||||
push_decl::<CommandStream>(&cfg, &mut output);
|
push_decl::<CommandStream>(&cfg, &mut output);
|
||||||
@@ -72,6 +73,8 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<CommandEvent>(&cfg, &mut output);
|
push_decl::<CommandEvent>(&cfg, &mut output);
|
||||||
push_decl::<CompactionLifecycleState>(&cfg, &mut output);
|
push_decl::<CompactionLifecycleState>(&cfg, &mut output);
|
||||||
push_decl::<CompactionLifecycle>(&cfg, &mut output);
|
push_decl::<CompactionLifecycle>(&cfg, &mut output);
|
||||||
|
push_decl::<CompactionPhase>(&cfg, &mut output);
|
||||||
|
push_decl::<CompactionTrigger>(&cfg, &mut output);
|
||||||
push_decl::<UploadedFileAvailability>(&cfg, &mut output);
|
push_decl::<UploadedFileAvailability>(&cfg, &mut output);
|
||||||
push_decl::<UploadedFileRef>(&cfg, &mut output);
|
push_decl::<UploadedFileRef>(&cfg, &mut output);
|
||||||
push_decl::<ScopeRule>(&cfg, &mut output);
|
push_decl::<ScopeRule>(&cfg, &mut output);
|
||||||
@@ -80,6 +83,7 @@ pub fn generated_protocol_types() -> String {
|
|||||||
push_decl::<RewindTarget>(&cfg, &mut output);
|
push_decl::<RewindTarget>(&cfg, &mut output);
|
||||||
push_decl::<RewindSummary>(&cfg, &mut output);
|
push_decl::<RewindSummary>(&cfg, &mut output);
|
||||||
push_decl::<InFlightBlock>(&cfg, &mut output);
|
push_decl::<InFlightBlock>(&cfg, &mut output);
|
||||||
|
push_decl::<InFlightCompaction>(&cfg, &mut output);
|
||||||
push_decl::<InFlightSnapshot>(&cfg, &mut output);
|
push_decl::<InFlightSnapshot>(&cfg, &mut output);
|
||||||
push_decl::<SessionEntryProvenance>(&cfg, &mut output);
|
push_decl::<SessionEntryProvenance>(&cfg, &mut output);
|
||||||
push_decl::<SessionMessageRole>(&cfg, &mut output);
|
push_decl::<SessionMessageRole>(&cfg, &mut output);
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ license.workspace = true
|
|||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
session-store = { workspace = true }
|
session-store = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -1,29 +1,39 @@
|
|||||||
# session-metrics
|
# session-metrics
|
||||||
|
|
||||||
## Role
|
Session 単位の append-only な観測値を既存 session-log に記録し、明示的な
|
||||||
|
metrics 読取 / JSONL export 経路で取り出すための小さなヘルパークレートです。
|
||||||
|
|
||||||
`session-metrics` records usage and memory/session metrics that are useful for diagnostics and maintenance.
|
- 保存先は `session-store` の `LogEntry::Extension`
|
||||||
|
- extension domain は `metrics`
|
||||||
|
- metric は `name / ts / dimensions / value / correlation_id` の最小 envelope
|
||||||
|
- `record_metric` で指定した Session / Segment に append する
|
||||||
|
- `read_segment_metrics` は 1 Segment、`read_session_metrics` は Session 内の全
|
||||||
|
Segment を読み、各 metric に `segment_id` と `compacted_from` を付ける
|
||||||
|
- `export_metrics_jsonl` はその located metric を newline-delimited JSON にする
|
||||||
|
- 通常の Session snapshot / Worker list / Worker detail は Extension を公開しない
|
||||||
|
|
||||||
## Boundaries
|
compaction は `compact.start` を source Segment、`compact.finish` と
|
||||||
|
`compact.post_request` を結果 Segment に記録する。同じ `correlation_id` と
|
||||||
|
`SegmentStart.compacted_from` により、Segment をまたぐ attempt と次の通常 LLM
|
||||||
|
request を結合できる。
|
||||||
|
|
||||||
Owns:
|
```rust,ignore
|
||||||
|
use session_metrics::{
|
||||||
|
Metric, export_metrics_jsonl, read_session_metrics, record_metric,
|
||||||
|
};
|
||||||
|
|
||||||
- metric record types and persistence helpers
|
let metric = Metric::now("compact.start")
|
||||||
- explicit memory usage/read/reference observations where applicable
|
.with_value(12_345.0)
|
||||||
- lightweight diagnostic data that should not become model context by itself
|
.with_dimension("trigger", "pre_run")
|
||||||
|
.with_correlation_id("018f6f8a-9822-7b11-8b35-706f30313700");
|
||||||
|
record_metric(
|
||||||
|
&store,
|
||||||
|
location.session_id,
|
||||||
|
location.segment_id,
|
||||||
|
&metric,
|
||||||
|
)?;
|
||||||
|
|
||||||
Does not own:
|
let records = read_session_metrics(&store, location.session_id)?;
|
||||||
|
let jsonl = export_metrics_jsonl(&records)?;
|
||||||
- prompt context packing (`agen`)
|
# Ok::<(), Box<dyn std::error::Error>>(())
|
||||||
- generated memory contents (`memory`)
|
```
|
||||||
- provider billing semantics (`provider`)
|
|
||||||
- UI status rendering (`tui`)
|
|
||||||
|
|
||||||
## Design notes
|
|
||||||
|
|
||||||
Metrics are observations. They may guide compaction, memory effectiveness analysis, or UX, but they are not authoritative conversation history and should not smuggle hidden state into model input.
|
|
||||||
|
|
||||||
## See also
|
|
||||||
|
|
||||||
- [`../../docs/design/memory-knowledge.md`](../../docs/design/memory-knowledge.md)
|
|
||||||
- [`../../docs/design/compaction.md`](../../docs/design/compaction.md)
|
|
||||||
|
|||||||
@@ -12,13 +12,16 @@
|
|||||||
//! 観測できない値は `None` で明示する
|
//! 観測できない値は `None` で明示する
|
||||||
//! - 「後から埋まる値」(例: prune 発火直後の `cache_read_tokens`)は前 entry に
|
//! - 「後から埋まる値」(例: prune 発火直後の `cache_read_tokens`)は前 entry に
|
||||||
//! 書き戻さず、`correlation_id` を共有する別 metric として流す。集計は読み手で join
|
//! 書き戻さず、`correlation_id` を共有する別 metric として流す。集計は読み手で join
|
||||||
//! - 集計 / 可視化 API はこのクレートには無い。session-log を読めば取り出せる、
|
//! - 集計 / 可視化には [`read_session_metrics`] / [`read_segment_metrics`] /
|
||||||
//! までが到達点
|
//! [`export_metrics_jsonl`] の明示的な metrics 専用経路を使う。通常の
|
||||||
|
//! Session snapshot は `Extension` を公開しない。
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use session_store::{SegmentId, SessionId, Store, StoreError, save_extension, segment_log};
|
use session_store::{
|
||||||
|
LogEntry, SegmentId, SegmentOrigin, SessionId, Store, StoreError, save_extension, segment_log,
|
||||||
|
};
|
||||||
|
|
||||||
/// Domain tag used in `LogEntry::Extension` for all metrics records.
|
/// Domain tag used in `LogEntry::Extension` for all metrics records.
|
||||||
pub const DOMAIN: &str = "metrics";
|
pub const DOMAIN: &str = "metrics";
|
||||||
@@ -97,6 +100,165 @@ pub fn metrics_from_extensions(extensions: &[(String, serde_json::Value)]) -> Ve
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A metric together with its durable Session/Segment origin.
|
||||||
|
///
|
||||||
|
/// `compacted_from` is copied from the Segment start record so readers can
|
||||||
|
/// reconstruct compaction lineage without inferring relationships from metric
|
||||||
|
/// names or timestamps.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct LocatedMetric {
|
||||||
|
pub session_id: SessionId,
|
||||||
|
pub segment_id: SegmentId,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub compacted_from: Option<SegmentOrigin>,
|
||||||
|
pub log_index: usize,
|
||||||
|
pub metric: Metric,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum SessionMetricsError {
|
||||||
|
Store(StoreError),
|
||||||
|
MissingSegmentStart {
|
||||||
|
segment_id: SegmentId,
|
||||||
|
},
|
||||||
|
SessionMismatch {
|
||||||
|
requested: SessionId,
|
||||||
|
observed: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
},
|
||||||
|
Encode(serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SessionMetricsError {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Store(error) => write!(formatter, "session metrics store error: {error}"),
|
||||||
|
Self::MissingSegmentStart { segment_id } => {
|
||||||
|
write!(formatter, "segment {segment_id} has no start record")
|
||||||
|
}
|
||||||
|
Self::SessionMismatch {
|
||||||
|
requested,
|
||||||
|
observed,
|
||||||
|
segment_id,
|
||||||
|
} => write!(
|
||||||
|
formatter,
|
||||||
|
"segment {segment_id} belongs to session {observed}, not {requested}"
|
||||||
|
),
|
||||||
|
Self::Encode(error) => write!(formatter, "session metrics encode error: {error}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for SessionMetricsError {}
|
||||||
|
|
||||||
|
impl From<StoreError> for SessionMetricsError {
|
||||||
|
fn from(error: StoreError) -> Self {
|
||||||
|
Self::Store(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<serde_json::Error> for SessionMetricsError {
|
||||||
|
fn from(error: serde_json::Error) -> Self {
|
||||||
|
Self::Encode(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read metrics from one exact Segment.
|
||||||
|
///
|
||||||
|
/// This is an explicit metrics-only surface. It validates the Segment's
|
||||||
|
/// durable start record and retains the log position of each metric.
|
||||||
|
pub fn read_segment_metrics(
|
||||||
|
store: &dyn Store,
|
||||||
|
session_id: SessionId,
|
||||||
|
segment_id: SegmentId,
|
||||||
|
) -> Result<Vec<LocatedMetric>, SessionMetricsError> {
|
||||||
|
let entries = store.read_all(session_id, segment_id)?;
|
||||||
|
let (observed_session_id, compacted_from) = entries
|
||||||
|
.iter()
|
||||||
|
.find_map(|entry| match entry {
|
||||||
|
LogEntry::AnnotatedSegmentStart {
|
||||||
|
session_id,
|
||||||
|
compacted_from,
|
||||||
|
..
|
||||||
|
} => Some((*session_id, compacted_from.clone())),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.ok_or(SessionMetricsError::MissingSegmentStart { segment_id })?;
|
||||||
|
if observed_session_id != session_id {
|
||||||
|
return Err(SessionMetricsError::SessionMismatch {
|
||||||
|
requested: session_id,
|
||||||
|
observed: observed_session_id,
|
||||||
|
segment_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(entries
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(log_index, entry)| match entry {
|
||||||
|
LogEntry::Extension {
|
||||||
|
domain, payload, ..
|
||||||
|
} if domain == DOMAIN => {
|
||||||
|
serde_json::from_value::<Metric>(payload.clone())
|
||||||
|
.ok()
|
||||||
|
.map(|metric| LocatedMetric {
|
||||||
|
session_id,
|
||||||
|
segment_id,
|
||||||
|
compacted_from: compacted_from.clone(),
|
||||||
|
log_index,
|
||||||
|
metric,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read every metric for a Session across all of its Segments.
|
||||||
|
pub fn read_session_metrics(
|
||||||
|
store: &dyn Store,
|
||||||
|
session_id: SessionId,
|
||||||
|
) -> Result<Vec<LocatedMetric>, SessionMetricsError> {
|
||||||
|
let mut metrics = Vec::new();
|
||||||
|
for segment_id in store.list_segments(session_id)? {
|
||||||
|
metrics.extend(read_segment_metrics(store, session_id, segment_id)?);
|
||||||
|
}
|
||||||
|
metrics.sort_by(|left, right| {
|
||||||
|
(
|
||||||
|
left.metric.ts,
|
||||||
|
metric_phase_order(&left.metric.name),
|
||||||
|
left.segment_id,
|
||||||
|
left.log_index,
|
||||||
|
)
|
||||||
|
.cmp(&(
|
||||||
|
right.metric.ts,
|
||||||
|
metric_phase_order(&right.metric.name),
|
||||||
|
right.segment_id,
|
||||||
|
right.log_index,
|
||||||
|
))
|
||||||
|
});
|
||||||
|
Ok(metrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize located metrics as newline-delimited JSON for an explicit export.
|
||||||
|
pub fn export_metrics_jsonl(metrics: &[LocatedMetric]) -> Result<String, SessionMetricsError> {
|
||||||
|
let mut output = String::new();
|
||||||
|
for metric in metrics {
|
||||||
|
output.push_str(&serde_json::to_string(metric)?);
|
||||||
|
output.push('\n');
|
||||||
|
}
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metric_phase_order(name: &str) -> u8 {
|
||||||
|
match name {
|
||||||
|
"compact.start" => 0,
|
||||||
|
"compact.finish" => 2,
|
||||||
|
"compact.post_request" => 3,
|
||||||
|
_ => 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -151,6 +313,101 @@ mod tests {
|
|||||||
assert_eq!(metrics[1].name, "b");
|
assert_eq!(metrics[1].name, "b");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_reader_and_export_preserve_compaction_lineage() {
|
||||||
|
use session_store::FsStore;
|
||||||
|
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsStore::new(temp.path()).unwrap();
|
||||||
|
let session_id = SessionId::parse_str("018f6f8a-9822-7b11-8b35-706f30313701").unwrap();
|
||||||
|
let source_segment_id =
|
||||||
|
SegmentId::parse_str("018f6f8a-9822-7b11-8b35-706f30313702").unwrap();
|
||||||
|
let result_segment_id =
|
||||||
|
SegmentId::parse_str("018f6f8a-9822-7b11-8b35-706f30313703").unwrap();
|
||||||
|
let correlation_id = "018f6f8a-9822-7b11-8b35-706f30313700";
|
||||||
|
|
||||||
|
store
|
||||||
|
.create_segment(
|
||||||
|
session_id,
|
||||||
|
source_segment_id,
|
||||||
|
&[LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts: 1,
|
||||||
|
session_id,
|
||||||
|
system_prompt: None,
|
||||||
|
config: Default::default(),
|
||||||
|
history: Vec::new(),
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: None,
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut start = Metric::now("compact.start").with_correlation_id(correlation_id);
|
||||||
|
start.ts = 10;
|
||||||
|
record_metric(&store, session_id, source_segment_id, &start).unwrap();
|
||||||
|
|
||||||
|
let origin = SegmentOrigin {
|
||||||
|
segment_id: source_segment_id,
|
||||||
|
at_turn_index: 0,
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.create_segment(
|
||||||
|
session_id,
|
||||||
|
result_segment_id,
|
||||||
|
&[LogEntry::AnnotatedSegmentStart {
|
||||||
|
ts: 2,
|
||||||
|
session_id,
|
||||||
|
system_prompt: None,
|
||||||
|
config: Default::default(),
|
||||||
|
history: Vec::new(),
|
||||||
|
forked_from: None,
|
||||||
|
compacted_from: Some(origin.clone()),
|
||||||
|
}],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let mut finish = Metric::now("compact.finish").with_correlation_id(correlation_id);
|
||||||
|
finish.ts = 10;
|
||||||
|
record_metric(&store, session_id, result_segment_id, &finish).unwrap();
|
||||||
|
let mut post = Metric::now("compact.post_request").with_correlation_id(correlation_id);
|
||||||
|
post.ts = 11;
|
||||||
|
record_metric(&store, session_id, result_segment_id, &post).unwrap();
|
||||||
|
|
||||||
|
let source_metrics = read_segment_metrics(&store, session_id, source_segment_id).unwrap();
|
||||||
|
assert_eq!(source_metrics.len(), 1);
|
||||||
|
assert_eq!(source_metrics[0].compacted_from, None);
|
||||||
|
|
||||||
|
let metrics = read_session_metrics(&store, session_id).unwrap();
|
||||||
|
assert_eq!(metrics.len(), 3);
|
||||||
|
assert_eq!(metrics[0].metric.name, "compact.start");
|
||||||
|
let finish = metrics
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.metric.name == "compact.finish")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(finish.segment_id, result_segment_id);
|
||||||
|
assert_eq!(finish.compacted_from, Some(origin));
|
||||||
|
assert!(
|
||||||
|
metrics
|
||||||
|
.iter()
|
||||||
|
.all(|record| { record.metric.correlation_id.as_deref() == Some(correlation_id) })
|
||||||
|
);
|
||||||
|
|
||||||
|
let exported = export_metrics_jsonl(&metrics).unwrap();
|
||||||
|
let ordinary_snapshot = session_store::public_snapshot::project_current_session_snapshot(
|
||||||
|
&store.read_all(session_id, result_segment_id).unwrap(),
|
||||||
|
);
|
||||||
|
let ordinary_json = serde_json::to_string(&ordinary_snapshot).unwrap();
|
||||||
|
assert!(!ordinary_json.contains("compact.finish"));
|
||||||
|
assert!(!ordinary_json.contains("compact.post_request"));
|
||||||
|
let decoded = exported
|
||||||
|
.lines()
|
||||||
|
.map(|line| serde_json::from_str::<LocatedMetric>(line).unwrap())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(decoded, metrics);
|
||||||
|
|
||||||
|
let reopened = FsStore::new(temp.path()).unwrap();
|
||||||
|
let restored = read_session_metrics(&reopened, session_id).unwrap();
|
||||||
|
assert_eq!(restored, metrics);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fold_skips_undeserializable_payloads() {
|
fn fold_skips_undeserializable_payloads() {
|
||||||
// 将来 schema が変わって読めない payload も skip して落ちない。
|
// 将来 schema が変わって読めない payload も skip して落ちない。
|
||||||
|
|||||||
@@ -14,8 +14,24 @@
|
|||||||
|
|
||||||
use crate::{SegmentId, SessionId};
|
use crate::{SegmentId, SessionId};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||||
|
|
||||||
|
fn metadata_lock(path: &Path) -> Arc<Mutex<()>> {
|
||||||
|
static LOCKS: OnceLock<Mutex<HashMap<PathBuf, Weak<Mutex<()>>>>> = OnceLock::new();
|
||||||
|
let mut locks = LOCKS
|
||||||
|
.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
.lock()
|
||||||
|
.expect("metadata lock registry poisoned");
|
||||||
|
if let Some(lock) = locks.get(path).and_then(Weak::upgrade) {
|
||||||
|
return lock;
|
||||||
|
}
|
||||||
|
let lock = Arc::new(Mutex::new(()));
|
||||||
|
locks.insert(path.to_path_buf(), Arc::downgrade(&lock));
|
||||||
|
lock
|
||||||
|
}
|
||||||
|
|
||||||
/// Errors from Worker metadata persistence.
|
/// Errors from Worker metadata persistence.
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -63,6 +79,8 @@ pub struct WorkerSpawnedScopeRule {
|
|||||||
pub target: PathBuf,
|
pub target: PathBuf,
|
||||||
pub permission: String,
|
pub permission: String,
|
||||||
pub recursive: bool,
|
pub recursive: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub symlink_policy: protocol::SymlinkPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One child Worker spawned by this Worker and persisted with the spawner's
|
/// One child Worker spawned by this Worker and persisted with the spawner's
|
||||||
@@ -180,6 +198,24 @@ pub trait WorkerMetadataStore: Send + Sync {
|
|||||||
Ok(metadata)
|
Ok(metadata)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compare and swap the active Segment pointer while preserving unrelated metadata.
|
||||||
|
/// Returns `false` without mutation when the durable pointer no longer matches.
|
||||||
|
fn compare_and_swap_active(
|
||||||
|
&self,
|
||||||
|
worker_name: &str,
|
||||||
|
expected: &WorkerActiveSegmentRef,
|
||||||
|
replacement: WorkerActiveSegmentRef,
|
||||||
|
) -> Result<bool, WorkerStoreError> {
|
||||||
|
let mut matched = false;
|
||||||
|
self.update_by_name(worker_name, |metadata| {
|
||||||
|
if metadata.active.as_ref() == Some(expected) {
|
||||||
|
metadata.active = Some(replacement);
|
||||||
|
matched = true;
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
Ok(matched)
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
|
/// Set the active pointer while preserving spawned children, workspace ownership, and manifest snapshot.
|
||||||
fn set_active(
|
fn set_active(
|
||||||
&self,
|
&self,
|
||||||
@@ -328,6 +364,7 @@ pub trait WorkerMetadataStore: Send + Sync {
|
|||||||
pub struct WorkerAggregateStore {
|
pub struct WorkerAggregateStore {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
worker_name: String,
|
worker_name: String,
|
||||||
|
update_lock: Arc<Mutex<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkerAggregateStore {
|
impl WorkerAggregateStore {
|
||||||
@@ -339,7 +376,11 @@ impl WorkerAggregateStore {
|
|||||||
let worker_name = worker_name.into();
|
let worker_name = worker_name.into();
|
||||||
validate_worker_name(&worker_name)?;
|
validate_worker_name(&worker_name)?;
|
||||||
fs::create_dir_all(&root)?;
|
fs::create_dir_all(&root)?;
|
||||||
Ok(Self { root, worker_name })
|
Ok(Self {
|
||||||
|
update_lock: metadata_lock(&root),
|
||||||
|
root,
|
||||||
|
worker_name,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
|
fn validate_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
|
||||||
@@ -406,6 +447,47 @@ impl WorkerMetadataStore for WorkerAggregateStore {
|
|||||||
Ok(Some(metadata))
|
Ok(Some(metadata))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_by_name<F>(
|
||||||
|
&self,
|
||||||
|
worker_name: &str,
|
||||||
|
update: F,
|
||||||
|
) -> Result<WorkerMetadata, WorkerStoreError>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut WorkerMetadata),
|
||||||
|
{
|
||||||
|
let _guard = self
|
||||||
|
.update_lock
|
||||||
|
.lock()
|
||||||
|
.expect("metadata update lock poisoned");
|
||||||
|
let mut metadata = self
|
||||||
|
.read_by_name(worker_name)?
|
||||||
|
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
|
||||||
|
update(&mut metadata);
|
||||||
|
self.write(&metadata)?;
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_and_swap_active(
|
||||||
|
&self,
|
||||||
|
worker_name: &str,
|
||||||
|
expected: &WorkerActiveSegmentRef,
|
||||||
|
replacement: WorkerActiveSegmentRef,
|
||||||
|
) -> Result<bool, WorkerStoreError> {
|
||||||
|
let _guard = self
|
||||||
|
.update_lock
|
||||||
|
.lock()
|
||||||
|
.expect("metadata update lock poisoned");
|
||||||
|
let Some(mut metadata) = self.read_by_name(worker_name)? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if metadata.active.as_ref() != Some(expected) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
metadata.active = Some(replacement);
|
||||||
|
self.write(&metadata)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
||||||
Ok(if self.metadata_path().is_file() {
|
Ok(if self.metadata_path().is_file() {
|
||||||
vec![self.worker_name.clone()]
|
vec![self.worker_name.clone()]
|
||||||
@@ -432,6 +514,7 @@ impl WorkerMetadataStore for WorkerAggregateStore {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct FsWorkerStore {
|
pub struct FsWorkerStore {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
update_lock: Arc<Mutex<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FsWorkerStore {
|
impl FsWorkerStore {
|
||||||
@@ -439,7 +522,10 @@ impl FsWorkerStore {
|
|||||||
pub fn new(root: impl Into<PathBuf>) -> Result<Self, WorkerStoreError> {
|
pub fn new(root: impl Into<PathBuf>) -> Result<Self, WorkerStoreError> {
|
||||||
let root = root.into();
|
let root = root.into();
|
||||||
fs::create_dir_all(&root)?;
|
fs::create_dir_all(&root)?;
|
||||||
Ok(Self { root })
|
Ok(Self {
|
||||||
|
update_lock: metadata_lock(&root),
|
||||||
|
root,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn worker_dir(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
|
fn worker_dir(&self, worker_name: &str) -> Result<PathBuf, WorkerStoreError> {
|
||||||
@@ -455,12 +541,32 @@ impl FsWorkerStore {
|
|||||||
impl WorkerMetadataStore for FsWorkerStore {
|
impl WorkerMetadataStore for FsWorkerStore {
|
||||||
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
||||||
let path = self.metadata_path(&metadata.worker_name)?;
|
let path = self.metadata_path(&metadata.worker_name)?;
|
||||||
if let Some(parent) = path.parent() {
|
let mut content = serde_json::to_vec_pretty(metadata)?;
|
||||||
fs::create_dir_all(parent)?;
|
content.push(b'\n');
|
||||||
|
let parent = path.parent().expect("metadata path has parent");
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
let temp = parent.join(format!(
|
||||||
|
".metadata.json.tmp-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
uuid::Uuid::now_v7()
|
||||||
|
));
|
||||||
|
let result = (|| -> Result<(), WorkerStoreError> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file = std::fs::OpenOptions::new()
|
||||||
|
.write(true)
|
||||||
|
.create_new(true)
|
||||||
|
.open(&temp)?;
|
||||||
|
file.write_all(&content)?;
|
||||||
|
file.sync_all()?;
|
||||||
|
drop(file);
|
||||||
|
fs::rename(&temp, &path)?;
|
||||||
|
std::fs::File::open(parent)?.sync_all()?;
|
||||||
|
Ok(())
|
||||||
|
})();
|
||||||
|
if result.is_err() {
|
||||||
|
let _ = fs::remove_file(temp);
|
||||||
}
|
}
|
||||||
let content = serde_json::to_vec_pretty(metadata)?;
|
result
|
||||||
fs::write(path, content)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
||||||
@@ -473,6 +579,47 @@ impl WorkerMetadataStore for FsWorkerStore {
|
|||||||
Ok(Some(serde_json::from_str(&content)?))
|
Ok(Some(serde_json::from_str(&content)?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn update_by_name<F>(
|
||||||
|
&self,
|
||||||
|
worker_name: &str,
|
||||||
|
update: F,
|
||||||
|
) -> Result<WorkerMetadata, WorkerStoreError>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut WorkerMetadata),
|
||||||
|
{
|
||||||
|
let _guard = self
|
||||||
|
.update_lock
|
||||||
|
.lock()
|
||||||
|
.expect("metadata update lock poisoned");
|
||||||
|
let mut metadata = self
|
||||||
|
.read_by_name(worker_name)?
|
||||||
|
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
|
||||||
|
update(&mut metadata);
|
||||||
|
self.write(&metadata)?;
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_and_swap_active(
|
||||||
|
&self,
|
||||||
|
worker_name: &str,
|
||||||
|
expected: &WorkerActiveSegmentRef,
|
||||||
|
replacement: WorkerActiveSegmentRef,
|
||||||
|
) -> Result<bool, WorkerStoreError> {
|
||||||
|
let _guard = self
|
||||||
|
.update_lock
|
||||||
|
.lock()
|
||||||
|
.expect("metadata update lock poisoned");
|
||||||
|
let Some(mut metadata) = self.read_by_name(worker_name)? else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
if metadata.active.as_ref() != Some(expected) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
metadata.active = Some(replacement);
|
||||||
|
self.write(&metadata)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
||||||
let mut names = Vec::new();
|
let mut names = Vec::new();
|
||||||
if !self.root.exists() {
|
if !self.root.exists() {
|
||||||
@@ -648,6 +795,26 @@ where
|
|||||||
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
||||||
self.worker_metadata_store.read_by_name(worker_name)
|
self.worker_metadata_store.read_by_name(worker_name)
|
||||||
}
|
}
|
||||||
|
fn update_by_name<F>(
|
||||||
|
&self,
|
||||||
|
worker_name: &str,
|
||||||
|
update: F,
|
||||||
|
) -> Result<WorkerMetadata, WorkerStoreError>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut WorkerMetadata),
|
||||||
|
{
|
||||||
|
self.worker_metadata_store
|
||||||
|
.update_by_name(worker_name, update)
|
||||||
|
}
|
||||||
|
fn compare_and_swap_active(
|
||||||
|
&self,
|
||||||
|
worker_name: &str,
|
||||||
|
expected: &WorkerActiveSegmentRef,
|
||||||
|
replacement: WorkerActiveSegmentRef,
|
||||||
|
) -> Result<bool, WorkerStoreError> {
|
||||||
|
self.worker_metadata_store
|
||||||
|
.compare_and_swap_active(worker_name, expected, replacement)
|
||||||
|
}
|
||||||
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
||||||
self.worker_metadata_store.list_names()
|
self.worker_metadata_store.list_names()
|
||||||
}
|
}
|
||||||
@@ -662,6 +829,7 @@ where
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::{LogEntry, Store};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_metadata_manifest_snapshot_roundtrips() {
|
fn worker_metadata_manifest_snapshot_roundtrips() {
|
||||||
@@ -682,6 +850,25 @@ mod tests {
|
|||||||
assert_eq!(restored, metadata);
|
assert_eq!(restored, metadata);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn spawned_scope_rule_defaults_resolved_and_roundtrips_logical_policy() {
|
||||||
|
let legacy: WorkerSpawnedScopeRule = serde_json::from_value(serde_json::json!({
|
||||||
|
"target": "/workspace/src",
|
||||||
|
"permission": "read",
|
||||||
|
"recursive": true
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(legacy.symlink_policy, protocol::SymlinkPolicy::Resolved);
|
||||||
|
|
||||||
|
let logical = WorkerSpawnedScopeRule {
|
||||||
|
symlink_policy: protocol::SymlinkPolicy::Logical,
|
||||||
|
..legacy
|
||||||
|
};
|
||||||
|
let restored: WorkerSpawnedScopeRule =
|
||||||
|
serde_json::from_value(serde_json::to_value(&logical).unwrap()).unwrap();
|
||||||
|
assert_eq!(restored, logical);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_aggregate_store_writes_one_fixed_metadata_identity() {
|
fn worker_aggregate_store_writes_one_fixed_metadata_identity() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
@@ -835,6 +1022,7 @@ mod tests {
|
|||||||
target: std::path::Path::new("/tmp/delegated").into(),
|
target: std::path::Path::new("/tmp/delegated").into(),
|
||||||
permission: "write".into(),
|
permission: "write".into(),
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
};
|
};
|
||||||
store
|
store
|
||||||
.set_spawned_children(
|
.set_spawned_children(
|
||||||
@@ -862,4 +1050,183 @@ mod tests {
|
|||||||
assert_eq!(restored.reclaimed_children.len(), 1);
|
assert_eq!(restored.reclaimed_children.len(), 1);
|
||||||
assert_eq!(restored.reclaimed_children[0].scope_delegated, vec![scope]);
|
assert_eq!(restored.reclaimed_children[0].scope_delegated, vec![scope]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn staged_segment_is_invisible_until_cas_and_reopen_selects_committed_history() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let sessions = temp.path().join("sessions");
|
||||||
|
let workers = temp.path().join("workers");
|
||||||
|
let open = || {
|
||||||
|
CombinedStore::new(
|
||||||
|
crate::FsStore::new(&sessions).unwrap(),
|
||||||
|
FsWorkerStore::new(&workers).unwrap(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let store = open();
|
||||||
|
let session_id = crate::new_session_id();
|
||||||
|
let old_segment_id = crate::new_segment_id();
|
||||||
|
let new_segment_id = crate::new_segment_id();
|
||||||
|
let entry = |label: &str| LogEntry::Extension {
|
||||||
|
ts: 1,
|
||||||
|
domain: label.into(),
|
||||||
|
payload: serde_json::json!({}),
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.create_segment(session_id, old_segment_id, &[entry("old-history")])
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.write(&WorkerMetadata::new(
|
||||||
|
"agent",
|
||||||
|
Some(WorkerActiveSegmentRef::active_segment(
|
||||||
|
session_id,
|
||||||
|
old_segment_id,
|
||||||
|
)),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
store
|
||||||
|
.create_segment(session_id, new_segment_id, &[entry("new-history")])
|
||||||
|
.unwrap();
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let reopened = open();
|
||||||
|
assert_eq!(
|
||||||
|
reopened
|
||||||
|
.read_by_name("agent")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.active
|
||||||
|
.unwrap()
|
||||||
|
.segment_id,
|
||||||
|
Some(old_segment_id)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
reopened
|
||||||
|
.compare_and_swap_active(
|
||||||
|
"agent",
|
||||||
|
&WorkerActiveSegmentRef::active_segment(session_id, old_segment_id),
|
||||||
|
WorkerActiveSegmentRef::active_segment(session_id, new_segment_id),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
drop(reopened);
|
||||||
|
|
||||||
|
let reopened = open();
|
||||||
|
assert_eq!(
|
||||||
|
reopened
|
||||||
|
.read_by_name("agent")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.active
|
||||||
|
.unwrap()
|
||||||
|
.segment_id,
|
||||||
|
Some(new_segment_id)
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
reopened.read_all(session_id, new_segment_id).unwrap().as_slice(),
|
||||||
|
[LogEntry::Extension { domain, .. }] if domain == "new-history"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn aggregate_store_uses_expected_old_segment_cas() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = WorkerAggregateStore::new(temp.path(), "agent").unwrap();
|
||||||
|
let session_id = crate::new_session_id();
|
||||||
|
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
|
||||||
|
store
|
||||||
|
.write(&WorkerMetadata::new("agent", Some(old.clone())))
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.compare_and_swap_active(
|
||||||
|
"agent",
|
||||||
|
&old,
|
||||||
|
WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id()),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!store
|
||||||
|
.compare_and_swap_active(
|
||||||
|
"agent",
|
||||||
|
&old,
|
||||||
|
WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id()),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn combined_store_delegates_atomic_active_segment_cas() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let metadata = FsWorkerStore::new(temp.path().join("workers")).unwrap();
|
||||||
|
let store = CombinedStore::new(
|
||||||
|
crate::FsStore::new(temp.path().join("sessions")).unwrap(),
|
||||||
|
metadata,
|
||||||
|
);
|
||||||
|
let session_id = crate::new_session_id();
|
||||||
|
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
|
||||||
|
store
|
||||||
|
.write(&WorkerMetadata::new("agent", Some(old.clone())))
|
||||||
|
.unwrap();
|
||||||
|
let barrier = Arc::new(std::sync::Barrier::new(3));
|
||||||
|
let handles = [crate::new_segment_id(), crate::new_segment_id()].map(|segment_id| {
|
||||||
|
let store = store.clone();
|
||||||
|
let old = old.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
store
|
||||||
|
.compare_and_swap_active(
|
||||||
|
"agent",
|
||||||
|
&old,
|
||||||
|
WorkerActiveSegmentRef::active_segment(session_id, segment_id),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
barrier.wait();
|
||||||
|
assert_eq!(
|
||||||
|
handles
|
||||||
|
.into_iter()
|
||||||
|
.map(|handle| handle.join().unwrap())
|
||||||
|
.filter(|won| *won)
|
||||||
|
.count(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_segment_cas_allows_exactly_one_concurrent_winner() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let store = FsWorkerStore::new(temp.path()).unwrap();
|
||||||
|
let session_id = crate::new_session_id();
|
||||||
|
let old = WorkerActiveSegmentRef::active_segment(session_id, crate::new_segment_id());
|
||||||
|
store
|
||||||
|
.write(&WorkerMetadata::new("agent", Some(old.clone())))
|
||||||
|
.unwrap();
|
||||||
|
let barrier = Arc::new(std::sync::Barrier::new(3));
|
||||||
|
let handles = [crate::new_segment_id(), crate::new_segment_id()].map(|segment_id| {
|
||||||
|
let store = store.clone();
|
||||||
|
let old = old.clone();
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
store
|
||||||
|
.compare_and_swap_active(
|
||||||
|
"agent",
|
||||||
|
&old,
|
||||||
|
WorkerActiveSegmentRef::active_segment(session_id, segment_id),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
barrier.wait();
|
||||||
|
let winners = handles
|
||||||
|
.into_iter()
|
||||||
|
.map(|handle| handle.join().unwrap())
|
||||||
|
.filter(|won| *won)
|
||||||
|
.count();
|
||||||
|
assert_eq!(winners, 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,10 +318,7 @@ impl StandaloneHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> {
|
pub async fn shutdown(mut self) -> Result<(), StandaloneShutdownError> {
|
||||||
let command = protocol::WorkerCommandEnvelope::for_snapshot(
|
let command = protocol::WorkerCommandEnvelope::new(u64::MAX);
|
||||||
u64::MAX,
|
|
||||||
&self.handle.shared_state.snapshot(),
|
|
||||||
);
|
|
||||||
let _ = self.handle.send(Method::Shutdown { command }).await;
|
let _ = self.handle.send(Method::Shutdown { command }).await;
|
||||||
let Some(shutdown) = self.shutdown.take() else {
|
let Some(shutdown) = self.shutdown.take() else {
|
||||||
self.retain_lease();
|
self.retain_lease();
|
||||||
@@ -504,10 +501,7 @@ fn active_pointer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn stop_started_worker(started: BootstrappedWorker) {
|
async fn stop_started_worker(started: BootstrappedWorker) {
|
||||||
let command = protocol::WorkerCommandEnvelope::for_snapshot(
|
let command = protocol::WorkerCommandEnvelope::new(u64::MAX);
|
||||||
u64::MAX,
|
|
||||||
&started.handle.shared_state.snapshot(),
|
|
||||||
);
|
|
||||||
let _ = started.handle.send(Method::Shutdown { command }).await;
|
let _ = started.handle.send(Method::Shutdown { command }).await;
|
||||||
let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await;
|
let _ = tokio::time::timeout(Duration::from_secs(2), started.shutdown).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,8 +191,7 @@ impl StandaloneWorkerStore {
|
|||||||
StandaloneStoreError::Io(error)
|
StandaloneStoreError::Io(error)
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
let record: StandaloneWorkerRecord = serde_json::from_slice(&bytes)
|
let record = decode_worker_record(id, &bytes)?;
|
||||||
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?;
|
|
||||||
if record.schema_version > SCHEMA_VERSION {
|
if record.schema_version > SCHEMA_VERSION {
|
||||||
return Err(StandaloneStoreError::NewerSchema {
|
return Err(StandaloneStoreError::NewerSchema {
|
||||||
id,
|
id,
|
||||||
@@ -408,7 +407,7 @@ impl StandaloneWorkerStore {
|
|||||||
.create_new(true)
|
.create_new(true)
|
||||||
.open(&temporary)
|
.open(&temporary)
|
||||||
.map_err(StandaloneStoreError::Io)?;
|
.map_err(StandaloneStoreError::Io)?;
|
||||||
serde_json::to_writer_pretty(&mut file, next).map_err(StandaloneStoreError::Json)?;
|
write_worker_record(&mut file, next)?;
|
||||||
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
|
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
|
||||||
file.sync_all().map_err(StandaloneStoreError::Io)?;
|
file.sync_all().map_err(StandaloneStoreError::Io)?;
|
||||||
fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
|
fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
|
||||||
@@ -428,8 +427,7 @@ impl StandaloneWorkerStore {
|
|||||||
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||||
let bytes =
|
let bytes =
|
||||||
fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
|
fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
|
||||||
serde_json::from_slice(&bytes)
|
decode_worker_record(id, &bytes)
|
||||||
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn worker_dir(&self, id: WorkerId) -> PathBuf {
|
fn worker_dir(&self, id: WorkerId) -> PathBuf {
|
||||||
@@ -634,6 +632,50 @@ fn observe_process(pid: u32) -> ProcessObservation {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn decode_worker_record(
|
||||||
|
id: WorkerId,
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
|
||||||
|
let decode = || -> Result<StandaloneWorkerRecord, serde_json::Error> {
|
||||||
|
let mut snapshot: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||||
|
let object = snapshot.as_object_mut().ok_or_else(|| {
|
||||||
|
serde_json::Error::io(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"standalone Worker record must be an object",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let persisted_manifest = object.remove("manifest").ok_or_else(|| {
|
||||||
|
serde_json::Error::io(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"standalone Worker record is missing manifest",
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let manifest = manifest::read_persisted_worker_manifest_snapshot(persisted_manifest)?;
|
||||||
|
object.insert("manifest".to_string(), serde_json::to_value(manifest)?);
|
||||||
|
serde_json::from_value(snapshot)
|
||||||
|
};
|
||||||
|
decode().map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_worker_record(
|
||||||
|
writer: &mut impl Write,
|
||||||
|
record: &StandaloneWorkerRecord,
|
||||||
|
) -> Result<(), StandaloneStoreError> {
|
||||||
|
let mut snapshot = serde_json::to_value(record).map_err(StandaloneStoreError::Json)?;
|
||||||
|
let object = snapshot.as_object_mut().ok_or_else(|| {
|
||||||
|
StandaloneStoreError::Json(serde_json::Error::io(io::Error::new(
|
||||||
|
io::ErrorKind::InvalidData,
|
||||||
|
"standalone Worker record must be an object",
|
||||||
|
)))
|
||||||
|
})?;
|
||||||
|
object.insert(
|
||||||
|
"manifest".to_string(),
|
||||||
|
manifest::write_persisted_worker_manifest_snapshot(&record.manifest)
|
||||||
|
.map_err(StandaloneStoreError::Json)?,
|
||||||
|
);
|
||||||
|
serde_json::to_writer_pretty(writer, &snapshot).map_err(StandaloneStoreError::Json)
|
||||||
|
}
|
||||||
|
|
||||||
fn now_unix_ms() -> Result<u64, StandaloneStoreError> {
|
fn now_unix_ms() -> Result<u64, StandaloneStoreError> {
|
||||||
let duration = SystemTime::now()
|
let duration = SystemTime::now()
|
||||||
.duration_since(UNIX_EPOCH)
|
.duration_since(UNIX_EPOCH)
|
||||||
@@ -709,7 +751,70 @@ pub enum StandaloneStoreError {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{LeaseLiveness, ProcessObservation, classify_lease_liveness};
|
use super::*;
|
||||||
|
|
||||||
|
fn test_manifest() -> WorkerManifest {
|
||||||
|
WorkerManifest::from_toml(
|
||||||
|
r#"
|
||||||
|
[worker]
|
||||||
|
name = "standalone-test"
|
||||||
|
|
||||||
|
[model]
|
||||||
|
scheme = "anthropic"
|
||||||
|
model_id = "claude-sonnet-4-20250514"
|
||||||
|
|
||||||
|
[engine]
|
||||||
|
|
||||||
|
[[scope.allow]]
|
||||||
|
target = "/tmp"
|
||||||
|
permission = "write"
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn standalone_record_uses_versioned_manifest_adapter_for_legacy_memory() {
|
||||||
|
let worker_id = "01a05782-d5dd-78f1-b9cd-ce37535bdb9d".parse().unwrap();
|
||||||
|
let manifest = test_manifest();
|
||||||
|
let record = StandaloneWorkerRecord {
|
||||||
|
schema_version: SCHEMA_VERSION,
|
||||||
|
revision: 6,
|
||||||
|
worker_id,
|
||||||
|
worker_name: manifest.worker.name.clone(),
|
||||||
|
storage_key: "standalone-test".to_string(),
|
||||||
|
cwd: StandaloneCwdIdentity {
|
||||||
|
canonical_path: PathBuf::from("/tmp"),
|
||||||
|
device: None,
|
||||||
|
inode: None,
|
||||||
|
},
|
||||||
|
manifest,
|
||||||
|
active_session_id: "01a05782-d5dd-78f1-b9cd-ce37535bdb9e".parse().unwrap(),
|
||||||
|
active_segment_id: None,
|
||||||
|
status: StandaloneWorkerStatus::Stopped,
|
||||||
|
created_at_unix_ms: 1,
|
||||||
|
updated_at_unix_ms: 2,
|
||||||
|
shutdown_reason: None,
|
||||||
|
};
|
||||||
|
let mut legacy = serde_json::to_value(&record).unwrap();
|
||||||
|
legacy["manifest"]["feature"]["memory"] = serde_json::json!({
|
||||||
|
"enabled": false,
|
||||||
|
"staging": false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let decoded =
|
||||||
|
decode_worker_record(worker_id, &serde_json::to_vec(&legacy).unwrap()).unwrap();
|
||||||
|
assert!(!decoded.manifest.feature.memory.profile.enabled);
|
||||||
|
|
||||||
|
let mut persisted = Vec::new();
|
||||||
|
write_worker_record(&mut persisted, &decoded).unwrap();
|
||||||
|
let persisted: serde_json::Value = serde_json::from_slice(&persisted).unwrap();
|
||||||
|
assert_eq!(persisted["manifest"]["schema_version"], 2);
|
||||||
|
assert_eq!(
|
||||||
|
persisted["manifest"]["manifest"]["feature"]["memory"]["profile"]["enabled"],
|
||||||
|
false
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn lease_liveness_requires_positive_live_or_stale_evidence() {
|
fn lease_liveness_requires_positive_live_or_stale_evidence() {
|
||||||
|
|||||||
@@ -300,11 +300,13 @@ mod tests {
|
|||||||
target: root.path().to_path_buf(),
|
target: root.path().to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
},
|
},
|
||||||
ScopeRule {
|
ScopeRule {
|
||||||
target: output.path().to_path_buf(),
|
target: output.path().to_path_buf(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
|
|||||||
@@ -298,7 +298,12 @@ mod tests {
|
|||||||
.execute(&inp.to_string(), Default::default())
|
.execute(&inp.to_string(), Default::default())
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
let msg = format!("{err}");
|
match err {
|
||||||
assert!(msg.contains("modified externally"), "{msg}");
|
ToolError::ExecutionFailed(message) => assert_eq!(
|
||||||
|
message,
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying: a.txt"
|
||||||
|
),
|
||||||
|
other => panic!("expected execution failure, got {other:?}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ impl From<ToolsError> for ToolError {
|
|||||||
| workdir::WorkdirError::Io { .. }
|
| workdir::WorkdirError::Io { .. }
|
||||||
| workdir::WorkdirError::Unavailable(_)
|
| workdir::WorkdirError::Unavailable(_)
|
||||||
| workdir::WorkdirError::OperationFailed
|
| workdir::WorkdirError::OperationFailed
|
||||||
| workdir::WorkdirError::Transport(_),
|
| workdir::WorkdirError::Transport(_)
|
||||||
|
| workdir::WorkdirError::Conflict(_),
|
||||||
) => ToolError::ExecutionFailed(err.to_string()),
|
) => ToolError::ExecutionFailed(err.to_string()),
|
||||||
ToolsError::FileSystem(_)
|
ToolsError::FileSystem(_)
|
||||||
| ToolsError::WorkdirSession(_)
|
| ToolsError::WorkdirSession(_)
|
||||||
@@ -55,3 +56,48 @@ impl From<ToolsError> for ToolError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use workdir::http::{WorkdirTransportError, WorkdirTransportErrorCode};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn local_workdir_content_conflict_is_retryable_execution_failure() {
|
||||||
|
let error = ToolError::from(ToolsError::WorkdirSession(
|
||||||
|
fs_operation::FsError::Conflict("src/main.rs".to_string()).into(),
|
||||||
|
));
|
||||||
|
|
||||||
|
match error {
|
||||||
|
ToolError::ExecutionFailed(message) => assert_eq!(
|
||||||
|
message,
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying: src/main.rs"
|
||||||
|
),
|
||||||
|
other => panic!("expected execution failure, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_workdir_content_conflict_is_retryable_without_host_path() {
|
||||||
|
let transport = WorkdirTransportError::from_workdir_error(
|
||||||
|
&workdir::WorkdirError::Conflict("/runtime/private/checkout/src/main.rs".to_string()),
|
||||||
|
);
|
||||||
|
assert_eq!(transport.code, WorkdirTransportErrorCode::Conflict);
|
||||||
|
assert_eq!(
|
||||||
|
transport.message,
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying"
|
||||||
|
);
|
||||||
|
let error = ToolError::from(ToolsError::WorkdirSession(transport.into_workdir_error()));
|
||||||
|
|
||||||
|
match error {
|
||||||
|
ToolError::ExecutionFailed(message) => {
|
||||||
|
assert_eq!(
|
||||||
|
message,
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying"
|
||||||
|
);
|
||||||
|
assert!(!message.contains("/runtime/private"));
|
||||||
|
}
|
||||||
|
other => panic!("expected execution failure, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -219,8 +219,13 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
let msg = format!("{err}");
|
match err {
|
||||||
assert!(msg.contains("modified externally"), "{msg}");
|
ToolError::ExecutionFailed(message) => assert_eq!(
|
||||||
|
message,
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying: a.txt"
|
||||||
|
),
|
||||||
|
other => panic!("expected execution failure, got {other:?}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ fn setup() -> (TempDir, TempDir, Registry) {
|
|||||||
target: spill.path().to_path_buf(),
|
target: spill.path().to_path_buf(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
});
|
});
|
||||||
let scope = Scope::from_config(&config).unwrap();
|
let scope = Scope::from_config(&config).unwrap();
|
||||||
let fs: WorkdirSessionHandle =
|
let fs: WorkdirSessionHandle =
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope {
|
|||||||
target: spill.to_path_buf(),
|
target: spill.to_path_buf(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
});
|
});
|
||||||
Scope::from_config(&config).unwrap()
|
Scope::from_config(&config).unwrap()
|
||||||
}
|
}
|
||||||
|
|||||||
+134
-50
@@ -277,6 +277,9 @@ pub struct App {
|
|||||||
/// Turn/protocol errors retained when a real `SegmentStart` replaces the
|
/// Turn/protocol errors retained when a real `SegmentStart` replaces the
|
||||||
/// replayable conversation rows during segment rotation.
|
/// replayable conversation rows during segment rotation.
|
||||||
run_error_messages: Vec<String>,
|
run_error_messages: Vec<String>,
|
||||||
|
/// Current compaction identity/revision used to fence snapshot/live updates.
|
||||||
|
active_compaction: Option<(String, u64)>,
|
||||||
|
pub compaction_progress: Option<protocol::InFlightCompaction>,
|
||||||
/// Presentation-only Internal Worker projections keyed by session identity.
|
/// Presentation-only Internal Worker projections keyed by session identity.
|
||||||
/// They are rendered in separate selectable views and never mixed into `blocks`.
|
/// They are rendered in separate selectable views and never mixed into `blocks`.
|
||||||
pub internal_workers: Vec<InternalWorkerView>,
|
pub internal_workers: Vec<InternalWorkerView>,
|
||||||
@@ -339,7 +342,7 @@ impl App {
|
|||||||
Self {
|
Self {
|
||||||
worker_name,
|
worker_name,
|
||||||
connected: false,
|
connected: false,
|
||||||
worker_state: WorkerStateSnapshot::initial(1),
|
worker_state: WorkerStateSnapshot::initial(),
|
||||||
next_command_id: 1,
|
next_command_id: 1,
|
||||||
worker_status: WorkerStatus::Idle,
|
worker_status: WorkerStatus::Idle,
|
||||||
running: false,
|
running: false,
|
||||||
@@ -364,6 +367,8 @@ impl App {
|
|||||||
quit_confirm: None,
|
quit_confirm: None,
|
||||||
shutdown_confirm: None,
|
shutdown_confirm: None,
|
||||||
blocks: Vec::new(),
|
blocks: Vec::new(),
|
||||||
|
active_compaction: None,
|
||||||
|
compaction_progress: None,
|
||||||
run_error_messages: Vec::new(),
|
run_error_messages: Vec::new(),
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
selected_internal_worker_session_id: None,
|
selected_internal_worker_session_id: None,
|
||||||
@@ -1123,25 +1128,14 @@ impl App {
|
|||||||
let command_id = self
|
let command_id = self
|
||||||
.next_command_id
|
.next_command_id
|
||||||
.max(self.worker_state.last_command_id.saturating_add(1));
|
.max(self.worker_state.last_command_id.saturating_add(1));
|
||||||
let command = WorkerCommandEnvelope::for_snapshot(command_id, &self.worker_state);
|
let command = WorkerCommandEnvelope::new(command_id);
|
||||||
self.next_command_id = command_id.saturating_add(1);
|
self.next_command_id = command_id.saturating_add(1);
|
||||||
command
|
command
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_worker_state_snapshot(&mut self, snapshot: &WorkerStateSnapshot) {
|
fn apply_worker_state_snapshot(&mut self, snapshot: &WorkerStateSnapshot) {
|
||||||
match protocol::apply_worker_state_snapshot(&mut self.worker_state, snapshot) {
|
self.worker_state = snapshot.clone();
|
||||||
Ok(protocol::WorkerStateSnapshotApply::Applied) => {
|
self.set_worker_status(self.worker_state.catalog_status());
|
||||||
self.set_worker_status(self.worker_state.catalog_status());
|
|
||||||
}
|
|
||||||
Ok(
|
|
||||||
protocol::WorkerStateSnapshotApply::Duplicate
|
|
||||||
| protocol::WorkerStateSnapshotApply::Stale,
|
|
||||||
) => {}
|
|
||||||
Err(error) => self.handle_error(
|
|
||||||
ErrorCode::Internal,
|
|
||||||
format!("worker state stream rejected: {error}"),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn handle_worker_event(&mut self, event: Event) -> Option<Method> {
|
pub fn handle_worker_event(&mut self, event: Event) -> Option<Method> {
|
||||||
@@ -1397,14 +1391,50 @@ impl App {
|
|||||||
self.reset_run_state();
|
self.reset_run_state();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::CompactStart { .. } => {
|
Event::CompactionProgress { compaction } => {
|
||||||
if self.last_streaming_compact_mut().is_none() {
|
self.compaction_progress = compaction.filter(|progress| {
|
||||||
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
matches!(
|
||||||
started_at: Instant::now(),
|
(&self.worker_state.state, progress.trigger),
|
||||||
}));
|
(
|
||||||
|
protocol::WorkerState::Busy(protocol::WorkerBusyState::Maintenance(
|
||||||
|
protocol::WorkerMaintenanceState::Compacting
|
||||||
|
)),
|
||||||
|
protocol::CompactionTrigger::Manual
|
||||||
|
) | (
|
||||||
|
protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(_)),
|
||||||
|
protocol::CompactionTrigger::PreRun
|
||||||
|
| protocol::CompactionTrigger::RequestThreshold
|
||||||
|
)
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Event::CompactStart { lifecycle } => {
|
||||||
|
let should_apply = match &self.active_compaction {
|
||||||
|
None => true,
|
||||||
|
Some((id, revision)) => {
|
||||||
|
id == &lifecycle.compaction_id && lifecycle.revision > *revision
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if should_apply {
|
||||||
|
self.active_compaction = Some((lifecycle.compaction_id, lifecycle.revision));
|
||||||
|
if self.last_streaming_compact_mut().is_none() {
|
||||||
|
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
||||||
|
started_at: Instant::now(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::CompactDone { lifecycle } => {
|
Event::CompactDone { lifecycle } => {
|
||||||
|
let should_apply = match &self.active_compaction {
|
||||||
|
None => true,
|
||||||
|
Some((id, revision)) => {
|
||||||
|
id == &lifecycle.compaction_id && lifecycle.revision > *revision
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !should_apply {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.active_compaction = None;
|
||||||
self.session_context_tokens = 0;
|
self.session_context_tokens = 0;
|
||||||
let new_segment_id = lifecycle
|
let new_segment_id = lifecycle
|
||||||
.new_segment_id
|
.new_segment_id
|
||||||
@@ -1430,6 +1460,16 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::CompactFailed { lifecycle } => {
|
Event::CompactFailed { lifecycle } => {
|
||||||
|
let should_apply = match &self.active_compaction {
|
||||||
|
None => true,
|
||||||
|
Some((id, revision)) => {
|
||||||
|
id == &lifecycle.compaction_id && lifecycle.revision > *revision
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !should_apply {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.active_compaction = None;
|
||||||
let error = lifecycle
|
let error = lifecycle
|
||||||
.error
|
.error
|
||||||
.unwrap_or_else(|| "compaction failed".to_string());
|
.unwrap_or_else(|| "compaction failed".to_string());
|
||||||
@@ -1470,9 +1510,9 @@ impl App {
|
|||||||
} => {
|
} => {
|
||||||
self.rewind_refresh_fence = false;
|
self.rewind_refresh_fence = false;
|
||||||
self.pending_submissions = session.pending_submissions.clone();
|
self.pending_submissions = session.pending_submissions.clone();
|
||||||
|
self.apply_worker_state_snapshot(&state);
|
||||||
self.restore_snapshot(&session, greeting, in_flight);
|
self.restore_snapshot(&session, greeting, in_flight);
|
||||||
self.replace_internal_worker_snapshots(internal_workers);
|
self.replace_internal_worker_snapshots(internal_workers);
|
||||||
self.apply_worker_state_snapshot(&state);
|
|
||||||
}
|
}
|
||||||
Event::InternalWorker {
|
Event::InternalWorker {
|
||||||
worker,
|
worker,
|
||||||
@@ -1485,9 +1525,19 @@ impl App {
|
|||||||
Event::WorkerState { snapshot } => {
|
Event::WorkerState { snapshot } => {
|
||||||
self.rewind_refresh_fence = false;
|
self.rewind_refresh_fence = false;
|
||||||
self.apply_worker_state_snapshot(&snapshot);
|
self.apply_worker_state_snapshot(&snapshot);
|
||||||
|
if let Some(progress) = self.compaction_progress.take() {
|
||||||
|
let _ = self.handle_worker_event(Event::CompactionProgress {
|
||||||
|
compaction: Some(progress),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Event::CommandAcknowledged { acknowledgement } => {
|
Event::CommandAcknowledged { acknowledgement } => {
|
||||||
self.apply_worker_state_snapshot(&acknowledgement.state);
|
self.apply_worker_state_snapshot(&acknowledgement.state);
|
||||||
|
if let Some(progress) = self.compaction_progress.take() {
|
||||||
|
let _ = self.handle_worker_event(Event::CompactionProgress {
|
||||||
|
compaction: Some(progress),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Command telemetry is an operational Web Console surface. The
|
// Command telemetry is an operational Web Console surface. The
|
||||||
// TUI continues to render the final Bash ToolResult from history.
|
// TUI continues to render the final Bash ToolResult from history.
|
||||||
@@ -1614,6 +1664,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn apply_in_flight_snapshot(&mut self, snapshot: InFlightSnapshot) {
|
fn apply_in_flight_snapshot(&mut self, snapshot: InFlightSnapshot) {
|
||||||
|
let compaction = snapshot.compaction;
|
||||||
for block in snapshot.blocks {
|
for block in snapshot.blocks {
|
||||||
match block {
|
match block {
|
||||||
InFlightBlock::Text { text, finished } => {
|
InFlightBlock::Text { text, finished } => {
|
||||||
@@ -1655,6 +1706,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.active_compaction = None;
|
||||||
|
let _ = self.handle_worker_event(Event::CompactionProgress { compaction });
|
||||||
}
|
}
|
||||||
|
|
||||||
fn append_assistant_text(&mut self, text: &str) {
|
fn append_assistant_text(&mut self, text: &str) {
|
||||||
@@ -3587,8 +3640,6 @@ mod completion_flow_tests {
|
|||||||
assert_eq!(app.worker_status, WorkerStatus::Idle);
|
assert_eq!(app.worker_status, WorkerStatus::Idle);
|
||||||
|
|
||||||
let running = WorkerStateSnapshot {
|
let running = WorkerStateSnapshot {
|
||||||
execution_generation: 1,
|
|
||||||
revision: 1,
|
|
||||||
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
||||||
protocol::WorkerRunState::Running,
|
protocol::WorkerRunState::Running,
|
||||||
)),
|
)),
|
||||||
@@ -3605,11 +3656,9 @@ mod completion_flow_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_state_events_and_acknowledgements_share_monotonic_application() {
|
fn worker_state_events_and_acknowledgements_replace_full_state() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
let running = WorkerStateSnapshot {
|
let running = WorkerStateSnapshot {
|
||||||
execution_generation: 4,
|
|
||||||
revision: 3,
|
|
||||||
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
||||||
protocol::WorkerRunState::Running,
|
protocol::WorkerRunState::Running,
|
||||||
)),
|
)),
|
||||||
@@ -3618,22 +3667,22 @@ mod completion_flow_tests {
|
|||||||
app.handle_worker_event(Event::WorkerState {
|
app.handle_worker_event(Event::WorkerState {
|
||||||
snapshot: running.clone(),
|
snapshot: running.clone(),
|
||||||
});
|
});
|
||||||
app.handle_worker_event(Event::WorkerState {
|
|
||||||
snapshot: WorkerStateSnapshot {
|
|
||||||
revision: 2,
|
|
||||||
state: protocol::WorkerState::Idle,
|
|
||||||
..running.clone()
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert_eq!(app.worker_state, running);
|
assert_eq!(app.worker_state, running);
|
||||||
|
|
||||||
|
let fresh_idle = WorkerStateSnapshot {
|
||||||
|
state: protocol::WorkerState::Idle,
|
||||||
|
last_command_id: 0,
|
||||||
|
};
|
||||||
|
app.handle_worker_event(Event::WorkerState {
|
||||||
|
snapshot: fresh_idle.clone(),
|
||||||
|
});
|
||||||
|
assert_eq!(app.worker_state, fresh_idle);
|
||||||
|
|
||||||
let paused = WorkerStateSnapshot {
|
let paused = WorkerStateSnapshot {
|
||||||
revision: 4,
|
|
||||||
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
||||||
protocol::WorkerRunState::Paused,
|
protocol::WorkerRunState::Paused,
|
||||||
)),
|
)),
|
||||||
last_command_id: 3,
|
last_command_id: 3,
|
||||||
..running.clone()
|
|
||||||
};
|
};
|
||||||
app.handle_worker_event(Event::CommandAcknowledged {
|
app.handle_worker_event(Event::CommandAcknowledged {
|
||||||
acknowledgement: protocol::WorkerCommandAcknowledgement {
|
acknowledgement: protocol::WorkerCommandAcknowledgement {
|
||||||
@@ -3644,17 +3693,6 @@ mod completion_flow_tests {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert_eq!(app.worker_state, paused);
|
assert_eq!(app.worker_state, paused);
|
||||||
|
|
||||||
app.handle_worker_event(Event::WorkerState {
|
|
||||||
snapshot: WorkerStateSnapshot {
|
|
||||||
state: protocol::WorkerState::Idle,
|
|
||||||
..paused.clone()
|
|
||||||
},
|
|
||||||
});
|
|
||||||
assert_eq!(app.worker_state, paused);
|
|
||||||
assert!(app.run_error_messages.iter().any(|message| {
|
|
||||||
message.contains("conflicting worker state snapshots at generation 4 revision 4")
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -3773,6 +3811,7 @@ mod completion_flow_tests {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
commands: Vec::new(),
|
commands: Vec::new(),
|
||||||
|
compaction: None,
|
||||||
},
|
},
|
||||||
internal_workers: Vec::new(),
|
internal_workers: Vec::new(),
|
||||||
});
|
});
|
||||||
@@ -4222,6 +4261,7 @@ mod completion_flow_tests {
|
|||||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||||
});
|
});
|
||||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
|
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Done);
|
||||||
|
lifecycle.revision = 2;
|
||||||
lifecycle.new_segment_id = Some(id.to_string());
|
lifecycle.new_segment_id = Some(id.to_string());
|
||||||
app.handle_worker_event(Event::CompactDone { lifecycle });
|
app.handle_worker_event(Event::CompactDone { lifecycle });
|
||||||
|
|
||||||
@@ -4243,6 +4283,7 @@ mod completion_flow_tests {
|
|||||||
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
lifecycle: test_compaction_lifecycle(protocol::CompactionLifecycleState::Running),
|
||||||
});
|
});
|
||||||
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
|
let mut lifecycle = test_compaction_lifecycle(protocol::CompactionLifecycleState::Failed);
|
||||||
|
lifecycle.revision = 2;
|
||||||
lifecycle.error = Some("provider 429".into());
|
lifecycle.error = Some("provider 429".into());
|
||||||
app.handle_worker_event(Event::CompactFailed { lifecycle });
|
app.handle_worker_event(Event::CompactFailed { lifecycle });
|
||||||
|
|
||||||
@@ -4256,6 +4297,52 @@ mod completion_flow_tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compaction_progress_is_hidden_when_worker_state_is_inconsistent() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
app.handle_worker_event(Event::CompactionProgress {
|
||||||
|
compaction: Some(protocol::InFlightCompaction {
|
||||||
|
phase: protocol::CompactionPhase::Preparing,
|
||||||
|
started_at_ms: 100,
|
||||||
|
trigger: protocol::CompactionTrigger::Manual,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert!(app.compaction_progress.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapshot_restores_and_runtime_clear_removes_compaction_progress() {
|
||||||
|
let mut app = App::new("test".into());
|
||||||
|
assert_eq!(app.worker_state.state, protocol::WorkerState::Idle);
|
||||||
|
let mut state = protocol::WorkerStateSnapshot::initial();
|
||||||
|
state.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Maintenance(
|
||||||
|
protocol::WorkerMaintenanceState::Compacting,
|
||||||
|
));
|
||||||
|
app.handle_worker_event(Event::Snapshot {
|
||||||
|
session: public_session(Vec::new()),
|
||||||
|
greeting: test_greeting(),
|
||||||
|
state,
|
||||||
|
in_flight: InFlightSnapshot {
|
||||||
|
compaction: Some(protocol::InFlightCompaction {
|
||||||
|
phase: protocol::CompactionPhase::Summarizing,
|
||||||
|
started_at_ms: 100,
|
||||||
|
trigger: protocol::CompactionTrigger::Manual,
|
||||||
|
}),
|
||||||
|
..InFlightSnapshot::default()
|
||||||
|
},
|
||||||
|
internal_workers: Vec::new(),
|
||||||
|
});
|
||||||
|
assert_eq!(compact_block_count(&app), 0);
|
||||||
|
assert_eq!(
|
||||||
|
app.compaction_progress.as_ref().map(|item| item.phase),
|
||||||
|
Some(protocol::CompactionPhase::Summarizing)
|
||||||
|
);
|
||||||
|
|
||||||
|
app.handle_worker_event(Event::CompactionProgress { compaction: None });
|
||||||
|
|
||||||
|
assert!(app.compaction_progress.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn shutdown_marks_live_compact_incomplete() {
|
fn shutdown_marks_live_compact_incomplete() {
|
||||||
let mut app = App::new("test".into());
|
let mut app = App::new("test".into());
|
||||||
@@ -4282,10 +4369,7 @@ mod completion_flow_tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn test_worker_state(status: WorkerStatus) -> WorkerStateSnapshot {
|
fn test_worker_state(status: WorkerStatus) -> WorkerStateSnapshot {
|
||||||
let mut snapshot = WorkerStateSnapshot::from(status);
|
WorkerStateSnapshot::from(status)
|
||||||
snapshot.execution_generation = 1;
|
|
||||||
snapshot.revision = 1;
|
|
||||||
snapshot
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn test_greeting() -> protocol::Greeting {
|
fn test_greeting() -> protocol::Greeting {
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ use std::io;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use client::{
|
use client::{
|
||||||
BackendRuntimeListTarget, BackendWorkerSummary, list_backend_stopped_workers,
|
BackendRuntimeListTarget, BackendWorkerOperationState, BackendWorkerRestoreResponse,
|
||||||
list_backend_workers, restore_backend_worker,
|
BackendWorkerSummary, list_backend_stopped_workers, list_backend_workers,
|
||||||
|
restore_backend_worker,
|
||||||
};
|
};
|
||||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
@@ -84,17 +85,20 @@ pub(crate) async fn run(
|
|||||||
let restore_target = target
|
let restore_target = target
|
||||||
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
|
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
|
||||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||||
restore_backend_worker(&restore_target)
|
let restore = restore_backend_worker(&restore_target)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
io::Error::other(format!(
|
io::Error::other(format!(
|
||||||
"failed to restore Backend worker {}/{}: {error}",
|
"failed to restore Backend worker {}/{}: {error}",
|
||||||
selected.runtime_id, selected.worker_id
|
selected.runtime_id, selected.worker_id
|
||||||
))
|
))
|
||||||
})?
|
})?;
|
||||||
.result
|
restored_worker(restore).map_err(|error| {
|
||||||
.worker
|
io::Error::other(format!(
|
||||||
.unwrap_or(selected)
|
"failed to restore Backend worker {}/{}: {error}",
|
||||||
|
selected.runtime_id, selected.worker_id
|
||||||
|
))
|
||||||
|
})?
|
||||||
} else {
|
} else {
|
||||||
selected
|
selected
|
||||||
};
|
};
|
||||||
@@ -105,6 +109,33 @@ pub(crate) async fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn restored_worker(response: BackendWorkerRestoreResponse) -> Result<BackendWorkerSummary, String> {
|
||||||
|
if response.result.state != BackendWorkerOperationState::Accepted {
|
||||||
|
let diagnostics = response
|
||||||
|
.result
|
||||||
|
.diagnostics
|
||||||
|
.iter()
|
||||||
|
.map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("; ");
|
||||||
|
let state = match response.result.state {
|
||||||
|
BackendWorkerOperationState::Accepted => unreachable!(),
|
||||||
|
BackendWorkerOperationState::Rejected => "rejected",
|
||||||
|
BackendWorkerOperationState::Unsupported => "unsupported",
|
||||||
|
};
|
||||||
|
return Err(if diagnostics.is_empty() {
|
||||||
|
format!("restore was {state} without a diagnostic")
|
||||||
|
} else {
|
||||||
|
format!("restore was {state}: {diagnostics}")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
response
|
||||||
|
.result
|
||||||
|
.worker
|
||||||
|
.ok_or_else(|| "restore was accepted without a Worker snapshot".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
|
fn dedup_workers(workers: &mut Vec<BackendWorkerSummary>) {
|
||||||
let mut seen = std::collections::HashSet::new();
|
let mut seen = std::collections::HashSet::new();
|
||||||
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
|
workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone())));
|
||||||
@@ -405,7 +436,8 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use client::{
|
use client::{
|
||||||
BackendWorkerCapabilitySummary, BackendWorkerImplementationSummary,
|
BackendDiagnostic, BackendDiagnosticSeverity, BackendWorkerCapabilitySummary,
|
||||||
|
BackendWorkerImplementationSummary, BackendWorkerRestoreResult,
|
||||||
BackendWorkerWorkspaceSummary,
|
BackendWorkerWorkspaceSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -427,8 +459,6 @@ mod tests {
|
|||||||
},
|
},
|
||||||
state: "idle".to_string(),
|
state: "idle".to_string(),
|
||||||
worker_state: Some(protocol::WorkerStateSnapshot {
|
worker_state: Some(protocol::WorkerStateSnapshot {
|
||||||
execution_generation: 1,
|
|
||||||
revision: 1,
|
|
||||||
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
state: protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
|
||||||
protocol::WorkerRunState::Running,
|
protocol::WorkerRunState::Running,
|
||||||
)),
|
)),
|
||||||
@@ -463,6 +493,67 @@ mod tests {
|
|||||||
text_width(&text[..byte_offset])
|
text_width(&text[..byte_offset])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn restore_response(
|
||||||
|
state: BackendWorkerOperationState,
|
||||||
|
worker: Option<BackendWorkerSummary>,
|
||||||
|
diagnostics: Vec<BackendDiagnostic>,
|
||||||
|
) -> BackendWorkerRestoreResponse {
|
||||||
|
BackendWorkerRestoreResponse {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
runtime_id: "runtime-a".to_string(),
|
||||||
|
worker_id: "worker-a".to_string(),
|
||||||
|
result: BackendWorkerRestoreResult {
|
||||||
|
state,
|
||||||
|
worker,
|
||||||
|
diagnostics,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejected_restore_surfaces_diagnostic_instead_of_attaching_selected_worker() {
|
||||||
|
let error = restored_worker(restore_response(
|
||||||
|
BackendWorkerOperationState::Rejected,
|
||||||
|
None,
|
||||||
|
vec![BackendDiagnostic {
|
||||||
|
code: "working_directory_not_found".to_string(),
|
||||||
|
severity: BackendDiagnosticSeverity::Error,
|
||||||
|
message: "working directory was not found".to_string(),
|
||||||
|
}],
|
||||||
|
))
|
||||||
|
.expect_err("rejected restore must not produce a Worker to attach");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
error,
|
||||||
|
"restore was rejected: working_directory_not_found: working directory was not found"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepted_restore_requires_returned_worker_snapshot() {
|
||||||
|
let error = restored_worker(restore_response(
|
||||||
|
BackendWorkerOperationState::Accepted,
|
||||||
|
None,
|
||||||
|
Vec::new(),
|
||||||
|
))
|
||||||
|
.expect_err("accepted restore without a Worker must not attach the stale selection");
|
||||||
|
|
||||||
|
assert_eq!(error, "restore was accepted without a Worker snapshot");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepted_restore_returns_authoritative_worker_snapshot() {
|
||||||
|
let worker = worker("runtime-a", "worker-a", Some("builtin:companion"));
|
||||||
|
let restored = restored_worker(restore_response(
|
||||||
|
BackendWorkerOperationState::Accepted,
|
||||||
|
Some(worker.clone()),
|
||||||
|
Vec::new(),
|
||||||
|
))
|
||||||
|
.expect("accepted restore should return its Worker snapshot");
|
||||||
|
|
||||||
|
assert_eq!(restored, worker);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_row_orders_and_simplifies_columns() {
|
fn worker_row_orders_and_simplifies_columns() {
|
||||||
let mut worker = worker("runtime-a", "worker-b", Some("builtin:coder"));
|
let mut worker = worker("runtime-a", "worker-b", Some("builtin:coder"));
|
||||||
@@ -475,7 +566,7 @@ mod tests {
|
|||||||
serde_json::from_value(serde_json::json!({
|
serde_json::from_value(serde_json::json!({
|
||||||
"working_directory_id": "001a06a9f0202000000",
|
"working_directory_id": "001a06a9f0202000000",
|
||||||
"repository_key": "main",
|
"repository_key": "main",
|
||||||
"materializer_kind": "local_git_worktree",
|
"materializer_kind": "runtime_git_clone",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"cleanliness": "clean"
|
"cleanliness": "clean"
|
||||||
}))
|
}))
|
||||||
@@ -500,8 +591,6 @@ mod tests {
|
|||||||
short.display_name = short.label.clone();
|
short.display_name = short.label.clone();
|
||||||
short.state = "idle".to_string();
|
short.state = "idle".to_string();
|
||||||
short.worker_state = Some(protocol::WorkerStateSnapshot {
|
short.worker_state = Some(protocol::WorkerStateSnapshot {
|
||||||
execution_generation: 1,
|
|
||||||
revision: 2,
|
|
||||||
state: protocol::WorkerState::Idle,
|
state: protocol::WorkerState::Idle,
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
});
|
});
|
||||||
@@ -518,7 +607,7 @@ mod tests {
|
|||||||
serde_json::from_value(serde_json::json!({
|
serde_json::from_value(serde_json::json!({
|
||||||
"working_directory_id": "workdir-1",
|
"working_directory_id": "workdir-1",
|
||||||
"repository_key": "main",
|
"repository_key": "main",
|
||||||
"materializer_kind": "local_git_worktree",
|
"materializer_kind": "runtime_git_clone",
|
||||||
"status": "active"
|
"status": "active"
|
||||||
}))
|
}))
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
|
|||||||
println!("Repository path/URI is required.");
|
println!("Repository path/URI is required.");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
let repository_name = prompt_line("Repository display name [Main]: ")?;
|
let repository_key = prompt_line("Repository key [main]: ")?;
|
||||||
let default_ref = prompt_line("Default ref [repository default]: ")?;
|
let default_ref = prompt_line("Default ref [repository default]: ")?;
|
||||||
let operation_key = format!(
|
let operation_key = format!(
|
||||||
"tui-workspace-create-{}-{}",
|
"tui-workspace-create-{}-{}",
|
||||||
@@ -204,11 +204,11 @@ fn prompt_create_request_inner() -> PickerResult<Option<CreateBackendWorkspaceRe
|
|||||||
display_name,
|
display_name,
|
||||||
repository: CreateBackendWorkspaceRepository {
|
repository: CreateBackendWorkspaceRepository {
|
||||||
uri,
|
uri,
|
||||||
display_name: Some(if repository_name.is_empty() {
|
repository_key: if repository_key.is_empty() {
|
||||||
"Main".to_string()
|
"main".to_string()
|
||||||
} else {
|
} else {
|
||||||
repository_name
|
repository_key
|
||||||
}),
|
},
|
||||||
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
default_ref: (!default_ref.is_empty()).then_some(default_ref),
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -410,10 +410,7 @@ fn compact_command(invocation: CommandInvocation<'_>) -> CommandExecution {
|
|||||||
let _ = invocation.args.raw();
|
let _ = invocation.args.raw();
|
||||||
CommandExecution {
|
CommandExecution {
|
||||||
method: Some(Method::Compact {
|
method: Some(Method::Compact {
|
||||||
command: protocol::WorkerCommandEnvelope::for_snapshot(
|
command: protocol::WorkerCommandEnvelope::new(0),
|
||||||
0,
|
|
||||||
&protocol::WorkerStateSnapshot::initial(1),
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
diagnostics: vec![CommandDiagnostic::new("compact requested")],
|
diagnostics: vec![CommandDiagnostic::new("compact requested")],
|
||||||
exit_command_mode: true,
|
exit_command_mode: true,
|
||||||
|
|||||||
+28
-6
@@ -139,10 +139,17 @@ fn draw_run_status(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn run_status_line(app: &App, now: Instant) -> Line<'static> {
|
fn run_status_line(app: &App, now: Instant) -> Line<'static> {
|
||||||
let elapsed = app
|
let elapsed = if let Some(progress) = &app.compaction_progress {
|
||||||
.run_started_at
|
let now_ms = std::time::SystemTime::now()
|
||||||
.and_then(|started_at| now.checked_duration_since(started_at))
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
.unwrap_or_default();
|
.unwrap_or_default()
|
||||||
|
.as_millis() as u64;
|
||||||
|
std::time::Duration::from_millis(now_ms.saturating_sub(progress.started_at_ms))
|
||||||
|
} else {
|
||||||
|
app.run_started_at
|
||||||
|
.and_then(|started_at| now.checked_duration_since(started_at))
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
let spinner_index =
|
let spinner_index =
|
||||||
((elapsed.as_millis() / RUN_SPINNER_FRAME_MS) as usize) % RUN_SPINNER_FRAMES.len();
|
((elapsed.as_millis() / RUN_SPINNER_FRAME_MS) as usize) % RUN_SPINNER_FRAMES.len();
|
||||||
let request_label = if app.run_requests == 1 {
|
let request_label = if app.run_requests == 1 {
|
||||||
@@ -151,7 +158,7 @@ fn run_status_line(app: &App, now: Instant) -> Line<'static> {
|
|||||||
format!("{} reqs", app.run_requests)
|
format!("{} reqs", app.run_requests)
|
||||||
};
|
};
|
||||||
|
|
||||||
Line::from(vec![
|
let mut spans = vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
RUN_SPINNER_FRAMES[spinner_index],
|
RUN_SPINNER_FRAMES[spinner_index],
|
||||||
Style::default()
|
Style::default()
|
||||||
@@ -159,6 +166,20 @@ fn run_status_line(app: &App, now: Instant) -> Line<'static> {
|
|||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
Span::raw(" "),
|
Span::raw(" "),
|
||||||
|
];
|
||||||
|
if let Some(progress) = &app.compaction_progress {
|
||||||
|
let phase = match progress.phase {
|
||||||
|
protocol::CompactionPhase::Preparing => "preparing",
|
||||||
|
protocol::CompactionPhase::Summarizing => "summarizing",
|
||||||
|
protocol::CompactionPhase::Committing => "committing",
|
||||||
|
};
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!("Compacting · {phase}"),
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
));
|
||||||
|
spans.push(Span::styled(" | ", Style::default().fg(Color::DarkGray)));
|
||||||
|
}
|
||||||
|
spans.extend([
|
||||||
Span::styled(
|
Span::styled(
|
||||||
fmt_run_elapsed(elapsed.as_secs()),
|
fmt_run_elapsed(elapsed.as_secs()),
|
||||||
Style::default().fg(Color::Gray),
|
Style::default().fg(Color::Gray),
|
||||||
@@ -177,7 +198,8 @@ fn run_status_line(app: &App, now: Instant) -> Line<'static> {
|
|||||||
fmt_tokens(app.run_output_tokens),
|
fmt_tokens(app.run_output_tokens),
|
||||||
Style::default().fg(Color::Yellow),
|
Style::default().fg(Color::Yellow),
|
||||||
),
|
),
|
||||||
])
|
]);
|
||||||
|
Line::from(spans)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn fmt_run_elapsed(secs: u64) -> String {
|
fn fmt_run_elapsed(secs: u64) -> String {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ fs-operation.workspace = true
|
|||||||
manifest.workspace = true
|
manifest.workspace = true
|
||||||
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
|
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json.workspace = true
|
||||||
sha2.workspace = true
|
sha2.workspace = true
|
||||||
tempfile.workspace = true
|
tempfile.workspace = true
|
||||||
thiserror.workspace = true
|
thiserror.workspace = true
|
||||||
|
|||||||
+85
-11
@@ -11,7 +11,8 @@ use crate::{
|
|||||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||||
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
|
ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId,
|
||||||
WorkdirSessionCapabilities, WriteRequest, WriteResult,
|
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSessionCapabilities,
|
||||||
|
WriteRequest, WriteResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
|
/// Opaque Runtime-owned identifier for one ephemeral Workdir session.
|
||||||
@@ -55,6 +56,8 @@ pub struct OpenWorkdirSessionResponse {
|
|||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "operation", content = "request", rename_all = "snake_case")]
|
#[serde(tag = "operation", content = "request", rename_all = "snake_case")]
|
||||||
pub enum WorkdirSessionOperation {
|
pub enum WorkdirSessionOperation {
|
||||||
|
AuthorizeScope(WorkdirScopeAuthorizationRequest),
|
||||||
|
ScopeRulesOverlap(WorkdirScopeOverlapRequest),
|
||||||
Stat(StatRequest),
|
Stat(StatRequest),
|
||||||
Read(ReadRequest),
|
Read(ReadRequest),
|
||||||
Write(WriteRequest),
|
Write(WriteRequest),
|
||||||
@@ -79,6 +82,8 @@ pub struct WorkdirSessionOperationRequest {
|
|||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(tag = "operation", content = "result", rename_all = "snake_case")]
|
#[serde(tag = "operation", content = "result", rename_all = "snake_case")]
|
||||||
pub enum WorkdirSessionOperationResult {
|
pub enum WorkdirSessionOperationResult {
|
||||||
|
AuthorizeScope,
|
||||||
|
ScopeRulesOverlap { overlaps: bool },
|
||||||
Stat(StatResult),
|
Stat(StatResult),
|
||||||
Read(ReadResult),
|
Read(ReadResult),
|
||||||
Write(WriteResult),
|
Write(WriteResult),
|
||||||
@@ -169,7 +174,10 @@ impl WorkdirTransportError {
|
|||||||
use WorkdirTransportErrorCode as Code;
|
use WorkdirTransportErrorCode as Code;
|
||||||
let (code, message) = match error {
|
let (code, message) = match error {
|
||||||
WorkdirError::NotFound(_) => (Code::NotFound, "Workdir path was not found"),
|
WorkdirError::NotFound(_) => (Code::NotFound, "Workdir path was not found"),
|
||||||
WorkdirError::Conflict(_) => (Code::Conflict, "Workdir content changed"),
|
WorkdirError::Conflict(_) => (
|
||||||
|
Code::Conflict,
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying",
|
||||||
|
),
|
||||||
WorkdirError::Unsupported(capability) => {
|
WorkdirError::Unsupported(capability) => {
|
||||||
return Self {
|
return Self {
|
||||||
code: Code::Unsupported,
|
code: Code::Unsupported,
|
||||||
@@ -293,7 +301,12 @@ mod client {
|
|||||||
/// implementations can mint short-lived capability tokens without making a
|
/// implementations can mint short-lived capability tokens without making a
|
||||||
/// Worker-bound session expire with the token used to open it.
|
/// Worker-bound session expire with the token used to open it.
|
||||||
pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync {
|
pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync {
|
||||||
fn bearer_token(&self) -> Result<String, WorkdirError>;
|
fn bearer_token(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path_and_query: &str,
|
||||||
|
body: &[u8],
|
||||||
|
) -> Result<String, WorkdirError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FixedBearerToken(Arc<str>);
|
struct FixedBearerToken(Arc<str>);
|
||||||
@@ -305,7 +318,12 @@ mod client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl WorkdirHttpAuthorization for FixedBearerToken {
|
impl WorkdirHttpAuthorization for FixedBearerToken {
|
||||||
fn bearer_token(&self) -> Result<String, WorkdirError> {
|
fn bearer_token(
|
||||||
|
&self,
|
||||||
|
_method: &str,
|
||||||
|
_path_and_query: &str,
|
||||||
|
_body: &[u8],
|
||||||
|
) -> Result<String, WorkdirError> {
|
||||||
Ok(self.0.to_string())
|
Ok(self.0.to_string())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,10 +372,14 @@ mod client {
|
|||||||
&base_url,
|
&base_url,
|
||||||
&["v1", "working-directories", workdir_id.as_str(), "sessions"],
|
&["v1", "working-directories", workdir_id.as_str(), "sessions"],
|
||||||
)?;
|
)?;
|
||||||
|
let body = serde_json::to_vec(&request)
|
||||||
|
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
|
||||||
|
let token = authorization.bearer_token("POST", url.path(), &body)?;
|
||||||
let response = client
|
let response = client
|
||||||
.post(url)
|
.post(url)
|
||||||
.bearer_auth(authorization.bearer_token()?)
|
.bearer_auth(token)
|
||||||
.json(&request)
|
.header("content-type", "application/json")
|
||||||
|
.body(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(http_unavailable)?;
|
.map_err(http_unavailable)?;
|
||||||
@@ -401,11 +423,15 @@ mod client {
|
|||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
let operation = WorkdirSessionOperationRequest { operation };
|
let operation = WorkdirSessionOperationRequest { operation };
|
||||||
|
let body = serde_json::to_vec(&operation)
|
||||||
|
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
|
||||||
|
let token = self.authorization.bearer_token("POST", url.path(), &body)?;
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
.post(url)
|
.post(url)
|
||||||
.bearer_auth(self.authorization.bearer_token()?)
|
.bearer_auth(token)
|
||||||
.json(&operation)
|
.header("content-type", "application/json")
|
||||||
|
.body(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(http_unavailable)?;
|
.map_err(http_unavailable)?;
|
||||||
@@ -429,6 +455,32 @@ mod client {
|
|||||||
self.capabilities
|
self.capabilities
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn authorize_scope_path(
|
||||||
|
&self,
|
||||||
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
|
) -> Result<(), WorkdirError> {
|
||||||
|
match self
|
||||||
|
.operate(WorkdirSessionOperation::AuthorizeScope(request))
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
WorkdirSessionOperationResult::AuthorizeScope => Ok(()),
|
||||||
|
_ => Err(Self::mismatch("authorize_scope")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scope_rules_overlap(
|
||||||
|
&self,
|
||||||
|
request: WorkdirScopeOverlapRequest,
|
||||||
|
) -> Result<bool, WorkdirError> {
|
||||||
|
match self
|
||||||
|
.operate(WorkdirSessionOperation::ScopeRulesOverlap(request))
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps } => Ok(overlaps),
|
||||||
|
_ => Err(Self::mismatch("scope_rules_overlap")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
||||||
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
||||||
@@ -543,10 +595,11 @@ mod client {
|
|||||||
&self.base_url,
|
&self.base_url,
|
||||||
&["v1", "workdir-sessions", self.session_id.as_str()],
|
&["v1", "workdir-sessions", self.session_id.as_str()],
|
||||||
)?;
|
)?;
|
||||||
|
let token = self.authorization.bearer_token("DELETE", url.path(), &[])?;
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
.delete(url)
|
.delete(url)
|
||||||
.bearer_auth(self.authorization.bearer_token()?)
|
.bearer_auth(token)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(http_unavailable)?;
|
.map_err(http_unavailable)?;
|
||||||
@@ -623,7 +676,7 @@ mod tests {
|
|||||||
(
|
(
|
||||||
WorkdirTransportErrorCode::Conflict,
|
WorkdirTransportErrorCode::Conflict,
|
||||||
409,
|
409,
|
||||||
"modified externally",
|
"The target file's content or existence changed since it was last observed",
|
||||||
),
|
),
|
||||||
(WorkdirTransportErrorCode::Unsupported, 400, "unsupported"),
|
(WorkdirTransportErrorCode::Unsupported, 400, "unsupported"),
|
||||||
(WorkdirTransportErrorCode::Denied, 403, "denied"),
|
(WorkdirTransportErrorCode::Denied, 403, "denied"),
|
||||||
@@ -665,7 +718,12 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
let transport = WorkdirTransportError {
|
let transport = WorkdirTransportError {
|
||||||
code,
|
code,
|
||||||
message: "safe provider message".to_string(),
|
message: if code == WorkdirTransportErrorCode::Conflict {
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying"
|
||||||
|
.to_string()
|
||||||
|
} else {
|
||||||
|
"safe provider message".to_string()
|
||||||
|
},
|
||||||
};
|
};
|
||||||
assert_eq!(code.http_status(), expected_status);
|
assert_eq!(code.http_status(), expected_status);
|
||||||
let workdir_error = transport.clone().into_workdir_error();
|
let workdir_error = transport.clone().into_workdir_error();
|
||||||
@@ -732,5 +790,21 @@ mod tests {
|
|||||||
transport.into_workdir_error(),
|
transport.into_workdir_error(),
|
||||||
WorkdirError::Io { .. }
|
WorkdirError::Io { .. }
|
||||||
));
|
));
|
||||||
|
|
||||||
|
let error = WorkdirError::Conflict(
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying: /secret/runtime/root/file"
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
|
let transport = WorkdirTransportError::from_workdir_error(&error);
|
||||||
|
assert_eq!(transport.code, WorkdirTransportErrorCode::Conflict);
|
||||||
|
assert_eq!(
|
||||||
|
transport.message,
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying"
|
||||||
|
);
|
||||||
|
assert!(!transport.message.contains("/secret"));
|
||||||
|
assert_eq!(
|
||||||
|
transport.into_workdir_error().to_string(),
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ pub use local::{
|
|||||||
};
|
};
|
||||||
pub use operation::*;
|
pub use operation::*;
|
||||||
pub use scope::{
|
pub use scope::{
|
||||||
ReadOnlyWorkdirSession, WorkdirScopeLease, WorkdirToolBroker, WorkdirToolScope,
|
ReadOnlyWorkdirSession, WorkdirScopeAuthorizationRequest, WorkdirScopeLease,
|
||||||
WorkdirToolScopePermission, WorkdirToolScopeRule,
|
WorkdirScopeOverlapRequest, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission,
|
||||||
|
WorkdirToolScopeRule,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Persistent, opaque identity of one materialized Workdir.
|
/// Persistent, opaque identity of one materialized Workdir.
|
||||||
@@ -147,6 +148,34 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
|||||||
fn workdir(&self) -> &Workdir;
|
fn workdir(&self) -> &Workdir;
|
||||||
fn capabilities(&self) -> WorkdirSessionCapabilities;
|
fn capabilities(&self) -> WorkdirSessionCapabilities;
|
||||||
|
|
||||||
|
/// Validate an attenuated filesystem rule at the provider boundary without
|
||||||
|
/// exposing the resolved host path. Providers that cannot resolve symbolic
|
||||||
|
/// links must reject resolved-policy checks rather than downgrade them.
|
||||||
|
async fn authorize_scope_path(
|
||||||
|
&self,
|
||||||
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
|
) -> Result<(), WorkdirError> {
|
||||||
|
if request.rules.iter().any(|rule| {
|
||||||
|
rule.symlink_policy == manifest::SymlinkPolicy::Logical
|
||||||
|
&& scope::rule_allows_path(rule, &request.path, request.permission)
|
||||||
|
}) {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(WorkdirError::Denied(
|
||||||
|
"Workdir provider cannot establish resolved scope authority".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scope_rules_overlap(
|
||||||
|
&self,
|
||||||
|
_request: WorkdirScopeOverlapRequest,
|
||||||
|
) -> Result<bool, WorkdirError> {
|
||||||
|
Err(WorkdirError::Denied(
|
||||||
|
"Workdir provider cannot compare resolved scope authority".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
|
||||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
|
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
|
||||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
|
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
|
||||||
@@ -206,7 +235,7 @@ pub enum WorkdirError {
|
|||||||
#[error("Workdir transport failed: {0}")]
|
#[error("Workdir transport failed: {0}")]
|
||||||
Transport(String),
|
Transport(String),
|
||||||
|
|
||||||
#[error("Workdir content was modified externally before the operation could be applied: {0}")]
|
#[error("{0}")]
|
||||||
Conflict(String),
|
Conflict(String),
|
||||||
|
|
||||||
#[error("unknown Workdir session command: {0}")]
|
#[error("unknown Workdir session command: {0}")]
|
||||||
@@ -320,7 +349,9 @@ impl From<fs_operation::FsError> for WorkdirError {
|
|||||||
fs_operation::FsError::SymlinkTargetIsDirectory { path, target } => {
|
fs_operation::FsError::SymlinkTargetIsDirectory { path, target } => {
|
||||||
Self::SymlinkTargetIsDirectory { path, target }
|
Self::SymlinkTargetIsDirectory { path, target }
|
||||||
}
|
}
|
||||||
fs_operation::FsError::Conflict(message) => Self::Conflict(message),
|
fs_operation::FsError::Conflict(path) => Self::Conflict(format!(
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying: {path}"
|
||||||
|
)),
|
||||||
fs_operation::FsError::InvalidGlob(message) => Self::InvalidGlob(message),
|
fs_operation::FsError::InvalidGlob(message) => Self::InvalidGlob(message),
|
||||||
fs_operation::FsError::InvalidRegex(message) => Self::InvalidRegex(message),
|
fs_operation::FsError::InvalidRegex(message) => Self::InvalidRegex(message),
|
||||||
fs_operation::FsError::InvalidArgument(message) => Self::InvalidArgument(message),
|
fs_operation::FsError::InvalidArgument(message) => Self::InvalidArgument(message),
|
||||||
|
|||||||
+381
-30
@@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex as StdMutex};
|
|||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use manifest::{Scope, SharedScope};
|
use manifest::{Permission, Scope, SharedScope, SymlinkPolicy};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::{Mutex, broadcast, watch};
|
use tokio::sync::{Mutex, broadcast, watch};
|
||||||
@@ -28,8 +28,10 @@ use crate::{
|
|||||||
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
||||||
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
|
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
|
||||||
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
|
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
|
||||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, WorkdirSession,
|
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath,
|
||||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WriteRequest, WriteResult,
|
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||||
|
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest,
|
||||||
|
WriteResult,
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::{EntryKind, WriteOutcome};
|
use crate::{EntryKind, WriteOutcome};
|
||||||
@@ -211,6 +213,52 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
|
|||||||
fn is_writable(&self, path: &Path) -> bool {
|
fn is_writable(&self, path: &Path) -> bool {
|
||||||
self.0.is_writable(path)
|
self.0.is_writable(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_readable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
||||||
|
matches!(
|
||||||
|
self.0.permission_at_paths(logical, resolved),
|
||||||
|
Some(Permission::Read | Permission::Write)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_writable_paths(&self, logical: &Path, resolved: &Path) -> bool {
|
||||||
|
self.0.permission_at_paths(logical, resolved) == Some(Permission::Write)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn path_sets_overlap(
|
||||||
|
left: &Path,
|
||||||
|
left_recursive: bool,
|
||||||
|
right: &Path,
|
||||||
|
right_recursive: bool,
|
||||||
|
) -> bool {
|
||||||
|
match (left_recursive, right_recursive) {
|
||||||
|
(true, true) => left.starts_with(right) || right.starts_with(left),
|
||||||
|
(true, false) => {
|
||||||
|
right.starts_with(left)
|
||||||
|
|| left == right
|
||||||
|
|| left.parent().is_some_and(|parent| parent == right)
|
||||||
|
}
|
||||||
|
(false, true) => {
|
||||||
|
left.starts_with(right)
|
||||||
|
|| left == right
|
||||||
|
|| right.parent().is_some_and(|parent| parent == left)
|
||||||
|
}
|
||||||
|
(false, false) => {
|
||||||
|
left == right
|
||||||
|
|| left.parent().is_some_and(|parent| parent == right)
|
||||||
|
|| right.parent().is_some_and(|parent| parent == left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rule_targets(
|
||||||
|
root: &Path,
|
||||||
|
rule: &crate::WorkdirToolScopeRule,
|
||||||
|
) -> std::io::Result<(PathBuf, PathBuf)> {
|
||||||
|
let logical = root.join(rule.target.as_str());
|
||||||
|
let resolved = fs_operation::resolve_access_path(&logical)?;
|
||||||
|
Ok((logical, resolved))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -397,6 +445,11 @@ impl LocalWorkdirSession {
|
|||||||
return Err(WorkdirError::RelativePath(path.to_path_buf()));
|
return Err(WorkdirError::RelativePath(path.to_path_buf()));
|
||||||
}
|
}
|
||||||
let symlink = first_symlink(path);
|
let symlink = first_symlink(path);
|
||||||
|
if let Some(info) = symlink.as_ref()
|
||||||
|
&& !info.target_exists
|
||||||
|
{
|
||||||
|
return Err(broken_symlink_error(path, info));
|
||||||
|
}
|
||||||
let scope = self.inner.scope.load();
|
let scope = self.inner.scope.load();
|
||||||
if !scope.is_readable(path) {
|
if !scope.is_readable(path) {
|
||||||
return Err(symlink_out_of_scope_or_plain(
|
return Err(symlink_out_of_scope_or_plain(
|
||||||
@@ -406,11 +459,6 @@ impl LocalWorkdirSession {
|
|||||||
&scope,
|
&scope,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
if let Some(info) = symlink.as_ref() {
|
|
||||||
if !info.target_exists {
|
|
||||||
return Err(broken_symlink_error(path, info));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
|
let meta = std::fs::metadata(path).map_err(|e| match e.kind() {
|
||||||
std::io::ErrorKind::NotFound => WorkdirError::NotFound(path.to_path_buf()),
|
std::io::ErrorKind::NotFound => WorkdirError::NotFound(path.to_path_buf()),
|
||||||
_ => WorkdirError::io(path, e),
|
_ => WorkdirError::io(path, e),
|
||||||
@@ -556,6 +604,86 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
self.inner.capabilities
|
self.inner.capabilities
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn authorize_scope_path(
|
||||||
|
&self,
|
||||||
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
|
) -> Result<(), WorkdirError> {
|
||||||
|
self.ensure_open()?;
|
||||||
|
let logical = self.inner.root.join(request.path.as_str());
|
||||||
|
let resolved = fs_operation::resolve_access_path(&logical)
|
||||||
|
.map_err(|error| WorkdirError::io(&logical, error))?;
|
||||||
|
let parent_permission = self
|
||||||
|
.inner
|
||||||
|
.scope
|
||||||
|
.load()
|
||||||
|
.permission_at_paths(&logical, &resolved);
|
||||||
|
let parent_allows = match request.permission {
|
||||||
|
WorkdirToolScopePermission::Read => matches!(
|
||||||
|
parent_permission,
|
||||||
|
Some(Permission::Read | Permission::Write)
|
||||||
|
),
|
||||||
|
WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write),
|
||||||
|
};
|
||||||
|
if !parent_allows {
|
||||||
|
return Err(WorkdirError::Denied(format!(
|
||||||
|
"Workdir path `{}` exceeds the provider attachment scope",
|
||||||
|
request.path
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let allowed = request.rules.iter().any(|rule| {
|
||||||
|
if request.permission == WorkdirToolScopePermission::Write
|
||||||
|
&& rule.permission != WorkdirToolScopePermission::Write
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let logical_target = self.inner.root.join(rule.target.as_str());
|
||||||
|
let (candidate, target) = match rule.symlink_policy {
|
||||||
|
SymlinkPolicy::Logical => (logical.as_path(), logical_target),
|
||||||
|
SymlinkPolicy::Resolved => {
|
||||||
|
let Ok(target) = fs_operation::resolve_access_path(&logical_target) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
(resolved.as_path(), target)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if rule.recursive {
|
||||||
|
candidate.starts_with(target)
|
||||||
|
} else {
|
||||||
|
candidate == target || candidate.parent() == Some(target.as_path())
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if allowed {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(WorkdirError::Denied(format!(
|
||||||
|
"Workdir path `{}` is outside the provider-resolved delegated scope",
|
||||||
|
request.path
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scope_rules_overlap(
|
||||||
|
&self,
|
||||||
|
request: WorkdirScopeOverlapRequest,
|
||||||
|
) -> Result<bool, WorkdirError> {
|
||||||
|
self.ensure_open()?;
|
||||||
|
let (left_logical, left_resolved) = rule_targets(&self.inner.root, &request.left)
|
||||||
|
.map_err(|error| WorkdirError::io(&self.inner.root, error))?;
|
||||||
|
let (right_logical, right_resolved) = rule_targets(&self.inner.root, &request.right)
|
||||||
|
.map_err(|error| WorkdirError::io(&self.inner.root, error))?;
|
||||||
|
Ok(path_sets_overlap(
|
||||||
|
&left_logical,
|
||||||
|
request.left.recursive,
|
||||||
|
&right_logical,
|
||||||
|
request.right.recursive,
|
||||||
|
) || path_sets_overlap(
|
||||||
|
&left_resolved,
|
||||||
|
request.left.recursive,
|
||||||
|
&right_resolved,
|
||||||
|
request.right.recursive,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||||
let logical = request.path.clone();
|
let logical = request.path.clone();
|
||||||
@@ -1334,6 +1462,22 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_logical_fs(dir: &TempDir) -> LocalWorkdirSession {
|
||||||
|
LocalWorkdirSession::new(
|
||||||
|
Scope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![ScopeRule {
|
||||||
|
target: dir.path().to_path_buf(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
symlink_policy: SymlinkPolicy::Logical,
|
||||||
|
}],
|
||||||
|
deny: Vec::new(),
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
dir.path().to_path_buf(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn logical_provider_operations_cover_read_write_edit_stat_and_list() {
|
async fn logical_provider_operations_cover_read_write_edit_stat_and_list() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -1425,7 +1569,10 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(error, WorkdirError::Conflict(_)));
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying: notes/item.txt"
|
||||||
|
);
|
||||||
|
|
||||||
std::fs::remove_file(dir.path().join("notes/item.txt")).unwrap();
|
std::fs::remove_file(dir.path().join("notes/item.txt")).unwrap();
|
||||||
let error = WorkdirSession::write(
|
let error = WorkdirSession::write(
|
||||||
@@ -1438,7 +1585,38 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(error, WorkdirError::Conflict(_)));
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying: notes/item.txt"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn write_conflicts_when_observed_absence_becomes_a_file() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let workdir = make_fs(&dir);
|
||||||
|
let path = WorkdirPath::new("race.txt").unwrap();
|
||||||
|
|
||||||
|
let error = WorkdirSession::stat(&workdir, StatRequest { path: path.clone() })
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, WorkdirError::NotFound(_)));
|
||||||
|
|
||||||
|
std::fs::write(dir.path().join("race.txt"), "created externally").unwrap();
|
||||||
|
let error = WorkdirSession::write(
|
||||||
|
&workdir,
|
||||||
|
WriteRequest {
|
||||||
|
path,
|
||||||
|
content: b"worker content".to_vec(),
|
||||||
|
expected_hash: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
"The target file's content or existence changed since it was last observed; read the file again before retrying: race.txt"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -1533,6 +1711,102 @@ mod tests {
|
|||||||
assert_eq!(read.bytes, b"persisted");
|
assert_eq!(read.bytes, b"persisted");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolved_provider_scope_rejects_read_and_write_through_outside_alias() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let outside = TempDir::new().unwrap();
|
||||||
|
let target = outside.path().join("target.txt");
|
||||||
|
fs::write(&target, "secret").unwrap();
|
||||||
|
symlink(&target, root.path().join("alias.txt")).unwrap();
|
||||||
|
symlink(outside.path(), root.path().join("alias-dir")).unwrap();
|
||||||
|
let workdir = make_fs(&root);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
WorkdirSession::read(
|
||||||
|
&workdir,
|
||||||
|
ReadRequest {
|
||||||
|
path: WorkdirPath::new("alias.txt").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 10,
|
||||||
|
max_bytes: 1024,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::SymlinkOutOfScope { .. })
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
WorkdirSession::write(
|
||||||
|
&workdir,
|
||||||
|
WriteRequest {
|
||||||
|
path: WorkdirPath::new("alias.txt").unwrap(),
|
||||||
|
content: b"changed".to_vec(),
|
||||||
|
expected_hash: None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::SymlinkOutOfScope { .. })
|
||||||
|
));
|
||||||
|
assert_eq!(fs::read_to_string(target).unwrap(), "secret");
|
||||||
|
assert!(matches!(
|
||||||
|
WorkdirSession::write(
|
||||||
|
&workdir,
|
||||||
|
WriteRequest {
|
||||||
|
path: WorkdirPath::new("alias-dir/new.txt").unwrap(),
|
||||||
|
content: b"new".to_vec(),
|
||||||
|
expected_hash: None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::ReadOnly(_))
|
||||||
|
));
|
||||||
|
assert!(!outside.path().join("new.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolved_deny_blocks_missing_write_through_logical_alias() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
let outside = TempDir::new().unwrap();
|
||||||
|
symlink(outside.path(), root.path().join("alias")).unwrap();
|
||||||
|
let workdir = LocalWorkdirSession::new(
|
||||||
|
Scope::from_config(&ScopeConfig {
|
||||||
|
allow: vec![ScopeRule {
|
||||||
|
target: root.path().to_path_buf(),
|
||||||
|
permission: Permission::Write,
|
||||||
|
recursive: true,
|
||||||
|
symlink_policy: SymlinkPolicy::Logical,
|
||||||
|
}],
|
||||||
|
deny: vec![ScopeRule {
|
||||||
|
target: outside.path().join("blocked.txt"),
|
||||||
|
permission: Permission::Read,
|
||||||
|
recursive: false,
|
||||||
|
symlink_policy: SymlinkPolicy::Logical,
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
root.path().to_path_buf(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
WorkdirSession::write(
|
||||||
|
&workdir,
|
||||||
|
WriteRequest {
|
||||||
|
path: WorkdirPath::new("alias/blocked.txt").unwrap(),
|
||||||
|
content: b"blocked".to_vec(),
|
||||||
|
expected_hash: None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::ReadOnly(_))
|
||||||
|
));
|
||||||
|
assert!(!outside.path().join("blocked.txt").exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn capability_boundary_rejects_direct_unsupported_operation() {
|
async fn capability_boundary_rejects_direct_unsupported_operation() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -1635,7 +1909,7 @@ mod tests {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn read_bytes_reports_symlink_target_outside_scope() {
|
fn read_bytes_allows_logical_symlink_path_with_target_outside_scope() {
|
||||||
use std::os::unix::fs::symlink;
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -1645,16 +1919,8 @@ mod tests {
|
|||||||
let link = dir.path().join("outside-repo.txt");
|
let link = dir.path().join("outside-repo.txt");
|
||||||
symlink(&target, &link).unwrap();
|
symlink(&target, &link).unwrap();
|
||||||
|
|
||||||
let fs = make_fs(&dir);
|
let fs = make_logical_fs(&dir);
|
||||||
let err = fs.read_bytes(&link).unwrap_err();
|
assert_eq!(fs.read_bytes(&link).unwrap(), b"secret");
|
||||||
assert!(
|
|
||||||
matches!(
|
|
||||||
err,
|
|
||||||
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "read" }
|
|
||||||
if path == &link && err_target == &target.canonicalize().unwrap()
|
|
||||||
),
|
|
||||||
"expected symlink out-of-scope diagnostic, got {err:?}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -1746,7 +2012,7 @@ mod tests {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[test]
|
#[test]
|
||||||
fn write_reports_symlink_target_outside_scope() {
|
fn write_allows_logical_symlink_path_with_target_outside_scope() {
|
||||||
use std::os::unix::fs::symlink;
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -1756,15 +2022,14 @@ mod tests {
|
|||||||
let link = dir.path().join("outside-repo.txt");
|
let link = dir.path().join("outside-repo.txt");
|
||||||
symlink(&target, &link).unwrap();
|
symlink(&target, &link).unwrap();
|
||||||
|
|
||||||
let fs = make_fs(&dir);
|
let fs = make_logical_fs(&dir);
|
||||||
let err = fs.write(&link, b"new").unwrap_err();
|
fs.write(&link, b"new").unwrap();
|
||||||
|
assert_eq!(fs::read(&target).unwrap(), b"new");
|
||||||
assert!(
|
assert!(
|
||||||
matches!(
|
fs::symlink_metadata(&link)
|
||||||
err,
|
.unwrap()
|
||||||
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "write" }
|
.file_type()
|
||||||
if path == &link && err_target == &target.canonicalize().unwrap()
|
.is_symlink()
|
||||||
),
|
|
||||||
"expected write symlink out-of-scope diagnostic, got {err:?}"
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1787,11 +2052,13 @@ mod tests {
|
|||||||
target: dir.path().to_path_buf(),
|
target: dir.path().to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: vec![ScopeRule {
|
deny: vec![ScopeRule {
|
||||||
target: sub.clone(),
|
target: sub.clone(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
let scope = Scope::from_config(&cfg).unwrap();
|
let scope = Scope::from_config(&cfg).unwrap();
|
||||||
@@ -1855,6 +2122,7 @@ mod tests {
|
|||||||
target: extra.path().to_path_buf(),
|
target: extra.path().to_path_buf(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1891,6 +2159,7 @@ mod tests {
|
|||||||
target: sub.clone(),
|
target: sub.clone(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1927,6 +2196,7 @@ mod tests {
|
|||||||
target: dir.path().to_path_buf(),
|
target: dir.path().to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}])
|
}])
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1942,6 +2212,83 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn provider_uses_explicit_logical_policy_through_symlinked_directories() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let outside = TempDir::new().unwrap();
|
||||||
|
std::fs::write(outside.path().join("worker.json"), "scope-needle\n").unwrap();
|
||||||
|
symlink(outside.path(), dir.path().join("yoi.local")).unwrap();
|
||||||
|
let workdir = make_logical_fs(&dir);
|
||||||
|
|
||||||
|
let read = WorkdirSession::read(
|
||||||
|
&workdir,
|
||||||
|
ReadRequest {
|
||||||
|
path: WorkdirPath::new("yoi.local/worker.json").unwrap(),
|
||||||
|
offset: 0,
|
||||||
|
limit: 100,
|
||||||
|
max_bytes: 1024,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(read.bytes, b"scope-needle\n");
|
||||||
|
let list = WorkdirSession::list(
|
||||||
|
&workdir,
|
||||||
|
ListRequest {
|
||||||
|
path: WorkdirPath::new("yoi.local").unwrap(),
|
||||||
|
limit: 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
list.entries[0].path,
|
||||||
|
WorkdirPath::new("yoi.local/worker.json").unwrap()
|
||||||
|
);
|
||||||
|
let glob = WorkdirSession::glob(
|
||||||
|
&workdir,
|
||||||
|
GlobRequest {
|
||||||
|
pattern: "**/*.json".into(),
|
||||||
|
path: WorkdirPath::new("yoi.local").unwrap(),
|
||||||
|
limit: 10,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
glob.paths,
|
||||||
|
[WorkdirPath::new("yoi.local/worker.json").unwrap()]
|
||||||
|
);
|
||||||
|
let grep = WorkdirSession::grep(
|
||||||
|
&workdir,
|
||||||
|
GrepRequest {
|
||||||
|
pattern: "scope-needle".into(),
|
||||||
|
path: WorkdirPath::new("yoi.local").unwrap(),
|
||||||
|
glob: Some("*.json".into()),
|
||||||
|
file_type: None,
|
||||||
|
case_insensitive: false,
|
||||||
|
before_context: 0,
|
||||||
|
after_context: 0,
|
||||||
|
multiline: false,
|
||||||
|
output_mode: crate::GrepOutputMode::Content,
|
||||||
|
limit: 10,
|
||||||
|
offset: 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(grep.match_count, 1);
|
||||||
|
assert!(grep.output.contains("yoi.local/worker.json"));
|
||||||
|
assert!(
|
||||||
|
!workdir
|
||||||
|
.scope()
|
||||||
|
.is_readable(&outside.path().join("worker.json"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn provider_executes_glob_grep_and_command_at_the_materialization() {
|
async fn provider_executes_glob_grep_and_command_at_the_materialization() {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
@@ -2029,11 +2376,13 @@ mod tests {
|
|||||||
target: dir.path().to_path_buf(),
|
target: dir.path().to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
},
|
},
|
||||||
ScopeRule {
|
ScopeRule {
|
||||||
target: spill.path().to_path_buf(),
|
target: spill.path().to_path_buf(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
@@ -2110,11 +2459,13 @@ mod tests {
|
|||||||
target: dir.path().to_path_buf(),
|
target: dir.path().to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
},
|
},
|
||||||
ScopeRule {
|
ScopeRule {
|
||||||
target: spill.path().to_path_buf(),
|
target: spill.path().to_path_buf(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
|
|||||||
+532
-139
@@ -8,6 +8,7 @@ use fs_operation::{
|
|||||||
EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest,
|
EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest,
|
||||||
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult,
|
||||||
};
|
};
|
||||||
|
use manifest::SymlinkPolicy;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
const MAX_SCOPED_COMMANDS: usize = 16;
|
const MAX_SCOPED_COMMANDS: usize = 16;
|
||||||
@@ -31,6 +32,25 @@ pub struct WorkdirToolScopeRule {
|
|||||||
pub target: FsPath,
|
pub target: FsPath,
|
||||||
pub permission: WorkdirToolScopePermission,
|
pub permission: WorkdirToolScopePermission,
|
||||||
pub recursive: bool,
|
pub recursive: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub symlink_policy: SymlinkPolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider-side check for one operation under an attenuated tool scope.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkdirScopeAuthorizationRequest {
|
||||||
|
pub rules: Vec<WorkdirToolScopeRule>,
|
||||||
|
pub path: FsPath,
|
||||||
|
pub permission: WorkdirToolScopePermission,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Provider-side overlap comparison that keeps resolved host paths private.
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkdirScopeOverlapRequest {
|
||||||
|
pub left: WorkdirToolScopeRule,
|
||||||
|
pub right: WorkdirToolScopeRule,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
@@ -70,6 +90,7 @@ impl WorkdirToolBroker {
|
|||||||
capabilities,
|
capabilities,
|
||||||
validity: SessionValidity::root(),
|
validity: SessionValidity::root(),
|
||||||
child_write_leases: Mutex::new(HashMap::new()),
|
child_write_leases: Mutex::new(HashMap::new()),
|
||||||
|
scope_lock: tokio::sync::Mutex::new(()),
|
||||||
next_lease_id: AtomicU64::new(1),
|
next_lease_id: AtomicU64::new(1),
|
||||||
close_lock: Arc::new(tokio::sync::Mutex::new(())),
|
close_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||||
owned_commands: Arc::new(Mutex::new(HashSet::new())),
|
owned_commands: Arc::new(Mutex::new(HashSet::new())),
|
||||||
@@ -310,6 +331,7 @@ struct ScopedWorkdirSession {
|
|||||||
capabilities: WorkdirSessionCapabilities,
|
capabilities: WorkdirSessionCapabilities,
|
||||||
validity: Arc<SessionValidity>,
|
validity: Arc<SessionValidity>,
|
||||||
child_write_leases: Mutex<HashMap<u64, ActiveWriteLease>>,
|
child_write_leases: Mutex<HashMap<u64, ActiveWriteLease>>,
|
||||||
|
scope_lock: tokio::sync::Mutex<()>,
|
||||||
next_lease_id: AtomicU64,
|
next_lease_id: AtomicU64,
|
||||||
close_lock: Arc<tokio::sync::Mutex<()>>,
|
close_lock: Arc<tokio::sync::Mutex<()>>,
|
||||||
owned_commands: Arc<Mutex<HashSet<String>>>,
|
owned_commands: Arc<Mutex<HashSet<String>>>,
|
||||||
@@ -374,9 +396,6 @@ impl ScopedWorkdirSession {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if permission == WorkdirToolScopePermission::Write {
|
|
||||||
self.ensure_parent_write_available(path)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,78 +482,84 @@ impl ScopedWorkdirSession {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
|
async fn ensure_parent_write_available(&self, path: &FsPath) -> Result<(), WorkdirError> {
|
||||||
let mut leases = self
|
let active_write_rules = {
|
||||||
.child_write_leases
|
let mut leases = self
|
||||||
.lock()
|
.child_write_leases
|
||||||
.expect("Workdir tool scope lease mutex poisoned");
|
.lock()
|
||||||
leases.retain(|_, lease| {
|
.expect("Workdir tool scope lease mutex poisoned");
|
||||||
lease
|
leases.retain(|_, lease| {
|
||||||
.validity
|
lease
|
||||||
.upgrade()
|
.validity
|
||||||
.is_some_and(|validity| validity.is_active())
|
|
||||||
|| lease
|
|
||||||
.cleanup_pending
|
|
||||||
.upgrade()
|
.upgrade()
|
||||||
.is_some_and(|pending| pending.load(Ordering::Acquire))
|
.is_some_and(|validity| validity.is_active())
|
||||||
});
|
|| lease
|
||||||
if leases.values().any(|lease| {
|
.cleanup_pending
|
||||||
lease.rules.iter().any(|rule| {
|
.upgrade()
|
||||||
rule.permission == WorkdirToolScopePermission::Write
|
.is_some_and(|pending| pending.load(Ordering::Acquire))
|
||||||
&& rule_allows_path(rule, path, WorkdirToolScopePermission::Write)
|
});
|
||||||
})
|
leases
|
||||||
}) {
|
.values()
|
||||||
Err(WorkdirError::Denied(format!(
|
.flat_map(|lease| lease.rules.iter().cloned())
|
||||||
"logical workdir path `{path}` is leased to child Workdir tools"
|
.collect::<Vec<_>>()
|
||||||
)))
|
};
|
||||||
} else {
|
let requested = WorkdirToolScopeRule {
|
||||||
Ok(())
|
target: path.clone(),
|
||||||
}
|
permission: WorkdirToolScopePermission::Write,
|
||||||
}
|
recursive: false,
|
||||||
|
symlink_policy: SymlinkPolicy::Resolved,
|
||||||
async fn ensure_source_path_has_no_symlink(&self, path: &FsPath) -> Result<(), WorkdirError> {
|
};
|
||||||
let mut current = String::new();
|
for active in active_write_rules {
|
||||||
for component in Path::new(path.as_str()).components() {
|
if self
|
||||||
let component = component.as_os_str().to_string_lossy();
|
.source
|
||||||
if component.is_empty() || component == "." {
|
.scope_rules_overlap(WorkdirScopeOverlapRequest {
|
||||||
continue;
|
left: active,
|
||||||
}
|
right: requested.clone(),
|
||||||
if !current.is_empty() {
|
})
|
||||||
current.push('/');
|
.await?
|
||||||
}
|
{
|
||||||
current.push_str(&component);
|
return Err(WorkdirError::Denied(format!(
|
||||||
let current = FsPath::new(¤t).map_err(|error| {
|
"path `{path}` is leased to child Workdir tools"
|
||||||
WorkdirError::Denied(format!("invalid scoped Workdir path: {error}"))
|
)));
|
||||||
})?;
|
|
||||||
match self.source.stat(StatRequest { path: current }).await {
|
|
||||||
Ok(result) if result.kind == fs_operation::EntryKind::Symlink => {
|
|
||||||
return Err(WorkdirError::Denied(format!(
|
|
||||||
"scoped Workdir path `{path}` traverses a symlink"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(_) => {}
|
|
||||||
Err(WorkdirError::NotFound(_)) => break,
|
|
||||||
Err(error) => return Err(error),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ensure_scope_targets_do_not_traverse_symlinks(
|
async fn ensure_scope_targets_are_authorized(
|
||||||
&self,
|
&self,
|
||||||
rules: &[WorkdirToolScopeRule],
|
rules: &[WorkdirToolScopeRule],
|
||||||
) -> Result<(), WorkdirError> {
|
) -> Result<(), WorkdirError> {
|
||||||
for rule in rules {
|
for rule in rules {
|
||||||
self.ensure_source_path_has_no_symlink(&rule.target).await?;
|
self.source
|
||||||
|
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
|
||||||
|
rules: rules.to_vec(),
|
||||||
|
path: rule.target.clone(),
|
||||||
|
permission: rule.permission,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn resolve_operation_path(&self, path: &FsPath) -> Result<FsPath, WorkdirError> {
|
async fn resolve_operation_path(
|
||||||
|
&self,
|
||||||
|
path: &FsPath,
|
||||||
|
permission: WorkdirToolScopePermission,
|
||||||
|
) -> Result<FsPath, WorkdirError> {
|
||||||
self.ensure_active()?;
|
self.ensure_active()?;
|
||||||
let resolved = self.resolve_path(path)?;
|
let resolved = self.resolve_path(path)?;
|
||||||
if self.scope.is_some() {
|
if permission == WorkdirToolScopePermission::Write {
|
||||||
self.ensure_source_path_has_no_symlink(&resolved).await?;
|
self.ensure_parent_write_available(&resolved).await?;
|
||||||
|
}
|
||||||
|
if let Some(rules) = self.scope.as_ref() {
|
||||||
|
self.source
|
||||||
|
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
|
||||||
|
rules: rules.clone(),
|
||||||
|
path: resolved.clone(),
|
||||||
|
permission,
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
Ok(resolved)
|
Ok(resolved)
|
||||||
}
|
}
|
||||||
@@ -613,6 +638,7 @@ impl ScopedWorkdirSession {
|
|||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
request: WorkdirToolScope,
|
request: WorkdirToolScope,
|
||||||
) -> Result<WorkdirScopeLease, WorkdirError> {
|
) -> Result<WorkdirScopeLease, WorkdirError> {
|
||||||
|
let _scope_guard = self.scope_lock.lock().await;
|
||||||
let capabilities = self.validate_scope(&request.rules, request.command)?;
|
let capabilities = self.validate_scope(&request.rules, request.command)?;
|
||||||
if !request
|
if !request
|
||||||
.rules
|
.rules
|
||||||
@@ -624,55 +650,66 @@ impl ScopedWorkdirSession {
|
|||||||
request.cwd
|
request.cwd
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
self.ensure_scope_targets_do_not_traverse_symlinks(&request.rules)
|
self.ensure_scope_targets_are_authorized(&request.rules)
|
||||||
.await?;
|
.await?;
|
||||||
let validity = SessionValidity::child(self.validity.clone());
|
let validity = SessionValidity::child(self.validity.clone());
|
||||||
let cleanup_pending = Arc::new(AtomicBool::new(true));
|
let cleanup_pending = Arc::new(AtomicBool::new(true));
|
||||||
let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed);
|
let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed);
|
||||||
if request
|
let write_rules = request
|
||||||
.rules
|
.rules
|
||||||
.iter()
|
.iter()
|
||||||
.any(|rule| rule.permission == WorkdirToolScopePermission::Write)
|
.filter(|rule| rule.permission == WorkdirToolScopePermission::Write)
|
||||||
{
|
.cloned()
|
||||||
let mut leases = self
|
.collect::<Vec<_>>();
|
||||||
.child_write_leases
|
if !write_rules.is_empty() {
|
||||||
.lock()
|
let active_write_rules = {
|
||||||
.expect("Workdir tool scope lease mutex poisoned");
|
let mut leases = self
|
||||||
leases.retain(|_, lease| {
|
.child_write_leases
|
||||||
lease
|
.lock()
|
||||||
.validity
|
.expect("Workdir tool scope lease mutex poisoned");
|
||||||
.upgrade()
|
leases.retain(|_, lease| {
|
||||||
.is_some_and(|validity| validity.is_active())
|
|
||||||
|| lease
|
|
||||||
.cleanup_pending
|
|
||||||
.upgrade()
|
|
||||||
.is_some_and(|pending| pending.load(Ordering::Acquire))
|
|
||||||
});
|
|
||||||
let requested_write_rules = request
|
|
||||||
.rules
|
|
||||||
.iter()
|
|
||||||
.filter(|rule| rule.permission == WorkdirToolScopePermission::Write);
|
|
||||||
for requested in requested_write_rules {
|
|
||||||
if leases.values().any(|lease| {
|
|
||||||
lease
|
lease
|
||||||
.rules
|
.validity
|
||||||
.iter()
|
.upgrade()
|
||||||
.any(|active| rules_overlap(active, requested))
|
.is_some_and(|validity| validity.is_active())
|
||||||
}) {
|
|| lease
|
||||||
return Err(WorkdirError::Denied(format!(
|
.cleanup_pending
|
||||||
"scoped write path `{}` overlaps an active child scope",
|
.upgrade()
|
||||||
requested.target
|
.is_some_and(|pending| pending.load(Ordering::Acquire))
|
||||||
)));
|
});
|
||||||
|
leases
|
||||||
|
.values()
|
||||||
|
.flat_map(|lease| lease.rules.iter().cloned())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
for requested in &write_rules {
|
||||||
|
for active in &active_write_rules {
|
||||||
|
if self
|
||||||
|
.source
|
||||||
|
.scope_rules_overlap(WorkdirScopeOverlapRequest {
|
||||||
|
left: active.clone(),
|
||||||
|
right: requested.clone(),
|
||||||
|
})
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
return Err(WorkdirError::Denied(format!(
|
||||||
|
"scoped write path `{}` overlaps an active child scope after provider resolution",
|
||||||
|
requested.target
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
leases.insert(
|
self.child_write_leases
|
||||||
id,
|
.lock()
|
||||||
ActiveWriteLease {
|
.expect("Workdir tool scope lease mutex poisoned")
|
||||||
validity: Arc::downgrade(&validity),
|
.insert(
|
||||||
cleanup_pending: Arc::downgrade(&cleanup_pending),
|
id,
|
||||||
rules: request.rules.clone(),
|
ActiveWriteLease {
|
||||||
},
|
validity: Arc::downgrade(&validity),
|
||||||
);
|
cleanup_pending: Arc::downgrade(&cleanup_pending),
|
||||||
|
rules: write_rules,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let owned_commands = Arc::new(Mutex::new(HashSet::new()));
|
let owned_commands = Arc::new(Mutex::new(HashSet::new()));
|
||||||
let pending_command_events = Arc::new(Mutex::new(HashMap::new()));
|
let pending_command_events = Arc::new(Mutex::new(HashMap::new()));
|
||||||
@@ -698,6 +735,7 @@ impl ScopedWorkdirSession {
|
|||||||
capabilities,
|
capabilities,
|
||||||
validity: validity.clone(),
|
validity: validity.clone(),
|
||||||
child_write_leases: Mutex::new(HashMap::new()),
|
child_write_leases: Mutex::new(HashMap::new()),
|
||||||
|
scope_lock: tokio::sync::Mutex::new(()),
|
||||||
next_lease_id: AtomicU64::new(1),
|
next_lease_id: AtomicU64::new(1),
|
||||||
close_lock: close_lock.clone(),
|
close_lock: close_lock.clone(),
|
||||||
owned_commands,
|
owned_commands,
|
||||||
@@ -736,49 +774,65 @@ impl WorkdirSession for ScopedWorkdirSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> {
|
async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
let path = self.resolve_operation_path(&request.path).await?;
|
let path = self
|
||||||
|
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||||
|
.await?;
|
||||||
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
||||||
request.path = path;
|
request.path = path;
|
||||||
self.source.stat(request).await
|
self.source.stat(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read(&self, mut request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
async fn read(&self, mut request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||||
let path = self.resolve_operation_path(&request.path).await?;
|
let path = self
|
||||||
|
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||||
|
.await?;
|
||||||
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
||||||
request.path = path;
|
request.path = path;
|
||||||
self.source.read(request).await
|
self.source.read(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||||
let path = self.resolve_operation_path(&request.path).await?;
|
let _scope_guard = self.scope_lock.lock().await;
|
||||||
|
let path = self
|
||||||
|
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Write)
|
||||||
|
.await?;
|
||||||
self.ensure_write(&path, WorkdirSessionCapability::Write)?;
|
self.ensure_write(&path, WorkdirSessionCapability::Write)?;
|
||||||
request.path = path;
|
request.path = path;
|
||||||
self.source.write(request).await
|
self.source.write(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn edit(&self, mut request: EditRequest) -> Result<EditResult, WorkdirError> {
|
async fn edit(&self, mut request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||||
let path = self.resolve_operation_path(&request.path).await?;
|
let _scope_guard = self.scope_lock.lock().await;
|
||||||
|
let path = self
|
||||||
|
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Write)
|
||||||
|
.await?;
|
||||||
self.ensure_write(&path, WorkdirSessionCapability::Edit)?;
|
self.ensure_write(&path, WorkdirSessionCapability::Edit)?;
|
||||||
request.path = path;
|
request.path = path;
|
||||||
self.source.edit(request).await
|
self.source.edit(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> {
|
async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||||
let path = self.resolve_operation_path(&request.path).await?;
|
let path = self
|
||||||
|
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||||
|
.await?;
|
||||||
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
|
||||||
request.path = path;
|
request.path = path;
|
||||||
self.source.list(request).await
|
self.source.list(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||||
let path = self.resolve_operation_path(&request.path).await?;
|
let path = self
|
||||||
|
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||||
|
.await?;
|
||||||
self.ensure_read(&path, WorkdirSessionCapability::Glob)?;
|
self.ensure_read(&path, WorkdirSessionCapability::Glob)?;
|
||||||
request.path = path;
|
request.path = path;
|
||||||
self.source.glob(request).await
|
self.source.glob(request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||||
let path = self.resolve_operation_path(&request.path).await?;
|
let path = self
|
||||||
|
.resolve_operation_path(&request.path, WorkdirToolScopePermission::Read)
|
||||||
|
.await?;
|
||||||
self.ensure_read(&path, WorkdirSessionCapability::Grep)?;
|
self.ensure_read(&path, WorkdirSessionCapability::Grep)?;
|
||||||
request.path = path;
|
request.path = path;
|
||||||
self.source.grep(request).await
|
self.source.grep(request).await
|
||||||
@@ -987,6 +1041,23 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
|
|||||||
WorkdirSessionCapabilities::READ_ONLY
|
WorkdirSessionCapabilities::READ_ONLY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn authorize_scope_path(
|
||||||
|
&self,
|
||||||
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
|
) -> Result<(), WorkdirError> {
|
||||||
|
if request.permission == WorkdirToolScopePermission::Write {
|
||||||
|
return Err(WorkdirError::Denied("read-only workdir session".into()));
|
||||||
|
}
|
||||||
|
self.inner.authorize_scope_path(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scope_rules_overlap(
|
||||||
|
&self,
|
||||||
|
request: WorkdirScopeOverlapRequest,
|
||||||
|
) -> Result<bool, WorkdirError> {
|
||||||
|
self.inner.scope_rules_overlap(request).await
|
||||||
|
}
|
||||||
|
|
||||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
self.inner.stat(request).await
|
self.inner.stat(request).await
|
||||||
}
|
}
|
||||||
@@ -1143,14 +1214,7 @@ fn unix_timestamp_ms() -> u64 {
|
|||||||
.min(u128::from(u64::MAX)) as u64
|
.min(u128::from(u64::MAX)) as u64
|
||||||
}
|
}
|
||||||
|
|
||||||
fn rules_overlap(left: &WorkdirToolScopeRule, right: &WorkdirToolScopeRule) -> bool {
|
pub(crate) fn rule_allows_path(
|
||||||
left.permission == WorkdirToolScopePermission::Write
|
|
||||||
&& right.permission == WorkdirToolScopePermission::Write
|
|
||||||
&& (rule_allows_path(left, &right.target, WorkdirToolScopePermission::Write)
|
|
||||||
|| rule_allows_path(right, &left.target, WorkdirToolScopePermission::Write))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rule_allows_path(
|
|
||||||
rule: &WorkdirToolScopeRule,
|
rule: &WorkdirToolScopeRule,
|
||||||
path: &FsPath,
|
path: &FsPath,
|
||||||
required: WorkdirToolScopePermission,
|
required: WorkdirToolScopePermission,
|
||||||
@@ -1182,6 +1246,11 @@ fn rule_contains_rule(parent: &WorkdirToolScopeRule, child: &WorkdirToolScopeRul
|
|||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
// Resolved < Logical: a child may narrow a Logical grant to Resolved,
|
||||||
|
// but cannot turn a Resolved parent grant into logical-alias authority.
|
||||||
|
if parent.symlink_policy < child.symlink_policy {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if !path_in_rule(parent, &child.target) {
|
if !path_in_rule(parent, &child.target) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -1212,6 +1281,7 @@ mod tests {
|
|||||||
target: root.to_path_buf(),
|
target: root.to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
})
|
})
|
||||||
@@ -1226,12 +1296,141 @@ mod tests {
|
|||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct BlockingWriteSession {
|
||||||
|
inner: Arc<LocalWorkdirSession>,
|
||||||
|
entered: tokio::sync::watch::Sender<bool>,
|
||||||
|
release: Arc<tokio::sync::Notify>,
|
||||||
|
block_next_write: std::sync::atomic::AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl WorkdirSession for BlockingWriteSession {
|
||||||
|
fn workdir(&self) -> &Workdir {
|
||||||
|
self.inner.workdir()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capabilities(&self) -> WorkdirSessionCapabilities {
|
||||||
|
self.inner.capabilities()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn authorize_scope_path(
|
||||||
|
&self,
|
||||||
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
|
) -> Result<(), WorkdirError> {
|
||||||
|
self.inner.authorize_scope_path(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scope_rules_overlap(
|
||||||
|
&self,
|
||||||
|
request: WorkdirScopeOverlapRequest,
|
||||||
|
) -> Result<bool, WorkdirError> {
|
||||||
|
self.inner.scope_rules_overlap(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
|
self.inner.stat(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError> {
|
||||||
|
self.inner.read(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError> {
|
||||||
|
if self.block_next_write.swap(false, Ordering::AcqRel) {
|
||||||
|
let _ = self.entered.send(true);
|
||||||
|
self.release.notified().await;
|
||||||
|
}
|
||||||
|
WorkdirSession::write(self.inner.as_ref(), request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn edit(&self, request: EditRequest) -> Result<EditResult, WorkdirError> {
|
||||||
|
self.inner.edit(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
|
||||||
|
self.inner.list(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn glob(&self, request: GlobRequest) -> Result<GlobResult, WorkdirError> {
|
||||||
|
self.inner.glob(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn grep(&self, request: GrepRequest) -> Result<GrepResult, WorkdirError> {
|
||||||
|
self.inner.grep(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start_command(
|
||||||
|
&self,
|
||||||
|
request: CommandRequest,
|
||||||
|
) -> Result<CommandHandle, WorkdirError> {
|
||||||
|
self.inner.start_command(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_status(
|
||||||
|
&self,
|
||||||
|
handle: CommandHandle,
|
||||||
|
) -> Result<CommandStatus, WorkdirError> {
|
||||||
|
self.inner.command_status(handle).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_output(
|
||||||
|
&self,
|
||||||
|
request: CommandOutputRequest,
|
||||||
|
) -> Result<CommandOutput, WorkdirError> {
|
||||||
|
self.inner.command_output(request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> {
|
||||||
|
self.inner.cancel_command(handle).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscribe_command_events(&self) -> Option<broadcast::Receiver<CommandEvent>> {
|
||||||
|
self.inner.subscribe_command_events()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn command_snapshot(&self) -> Vec<CommandSnapshot> {
|
||||||
|
self.inner.command_snapshot()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn close(&self) -> Result<(), WorkdirError> {
|
||||||
|
self.inner.close().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blocking_session(
|
||||||
|
root: &Path,
|
||||||
|
) -> (
|
||||||
|
WorkdirToolBroker,
|
||||||
|
tokio::sync::watch::Receiver<bool>,
|
||||||
|
Arc<tokio::sync::Notify>,
|
||||||
|
) {
|
||||||
|
let scope = SharedScope::new(Scope::writable(root).unwrap());
|
||||||
|
let inner = Arc::new(LocalWorkdirSession::materialized_bound(
|
||||||
|
Workdir::new("blocking-delegation-test"),
|
||||||
|
root.to_path_buf(),
|
||||||
|
root.to_path_buf(),
|
||||||
|
scope,
|
||||||
|
WorkdirSessionCapabilities::ALL,
|
||||||
|
));
|
||||||
|
let (entered, receiver) = tokio::sync::watch::channel(false);
|
||||||
|
let release = Arc::new(tokio::sync::Notify::new());
|
||||||
|
let source = Arc::new(BlockingWriteSession {
|
||||||
|
inner,
|
||||||
|
entered,
|
||||||
|
release: release.clone(),
|
||||||
|
block_next_write: std::sync::atomic::AtomicBool::new(true),
|
||||||
|
});
|
||||||
|
(WorkdirToolBroker::new(source), receiver, release)
|
||||||
|
}
|
||||||
|
|
||||||
fn request(path: &str, permission: WorkdirToolScopePermission) -> WorkdirToolScope {
|
fn request(path: &str, permission: WorkdirToolScopePermission) -> WorkdirToolScope {
|
||||||
WorkdirToolScope {
|
WorkdirToolScope {
|
||||||
rules: vec![WorkdirToolScopeRule {
|
rules: vec![WorkdirToolScopeRule {
|
||||||
target: fs_path(path),
|
target: fs_path(path),
|
||||||
permission,
|
permission,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
cwd: fs_path(path),
|
cwd: fs_path(path),
|
||||||
command: permission == WorkdirToolScopePermission::Write,
|
command: permission == WorkdirToolScopePermission::Write,
|
||||||
@@ -1342,6 +1541,7 @@ mod tests {
|
|||||||
target: fs_path("work"),
|
target: fs_path("work"),
|
||||||
permission: WorkdirToolScopePermission::Write,
|
permission: WorkdirToolScopePermission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
cwd: fs_path("work"),
|
cwd: fs_path("work"),
|
||||||
command: false,
|
command: false,
|
||||||
@@ -1429,12 +1629,24 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_rule_defaults_to_resolved_symlink_policy_on_restore() {
|
||||||
|
let rule: WorkdirToolScopeRule = serde_json::from_value(serde_json::json!({
|
||||||
|
"target": "src",
|
||||||
|
"permission": "read",
|
||||||
|
"recursive": true
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rule.symlink_policy, SymlinkPolicy::Resolved);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn non_recursive_rule_covers_target_and_direct_children_only() {
|
fn non_recursive_rule_covers_target_and_direct_children_only() {
|
||||||
let rule = WorkdirToolScopeRule {
|
let rule = WorkdirToolScopeRule {
|
||||||
target: fs_path("docs"),
|
target: fs_path("docs"),
|
||||||
permission: WorkdirToolScopePermission::Read,
|
permission: WorkdirToolScopePermission::Read,
|
||||||
recursive: false,
|
recursive: false,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
};
|
};
|
||||||
assert!(path_in_rule(&rule, &fs_path("docs")));
|
assert!(path_in_rule(&rule, &fs_path("docs")));
|
||||||
assert!(path_in_rule(&rule, &fs_path("docs/readme.md")));
|
assert!(path_in_rule(&rule, &fs_path("docs/readme.md")));
|
||||||
@@ -1487,7 +1699,7 @@ mod tests {
|
|||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn provider_scope_denies_read_through_symlink_outside_grant() {
|
async fn provider_scope_rejects_symlink_aliases_by_default() {
|
||||||
use std::os::unix::fs::symlink;
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
let root = TempDir::new().unwrap();
|
let root = TempDir::new().unwrap();
|
||||||
@@ -1501,60 +1713,241 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let result = child.read(read("link")).await;
|
assert!(matches!(
|
||||||
assert!(
|
child.read(read("link")).await,
|
||||||
result.is_err(),
|
Err(WorkdirError::Denied(message))
|
||||||
"symlink read escaped provider scope: {result:?}"
|
if message.contains("provider-resolved delegated scope")
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn provider_scope_denies_write_through_symlink_outside_grant() {
|
async fn resolved_scope_follows_its_target_but_rejects_nested_escape() {
|
||||||
use std::os::unix::fs::symlink;
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
let root = TempDir::new().unwrap();
|
let root = TempDir::new().unwrap();
|
||||||
fs::create_dir_all(root.path().join("granted")).unwrap();
|
fs::create_dir_all(root.path().join("target")).unwrap();
|
||||||
fs::create_dir_all(root.path().join("secret")).unwrap();
|
fs::create_dir_all(root.path().join("secret")).unwrap();
|
||||||
symlink("../secret", root.path().join("granted/outside")).unwrap();
|
fs::write(root.path().join("target/visible"), "visible").unwrap();
|
||||||
|
fs::write(root.path().join("secret/key"), "hidden").unwrap();
|
||||||
|
symlink("target", root.path().join("granted")).unwrap();
|
||||||
|
symlink("../secret/key", root.path().join("target/escape")).unwrap();
|
||||||
let parent = session(root.path());
|
let parent = session(root.path());
|
||||||
let child = parent
|
let child = parent
|
||||||
.scope(request("granted", WorkdirToolScopePermission::Write))
|
.scope(request("granted", WorkdirToolScopePermission::Read))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let result = child.write(write("outside/new", "forbidden")).await;
|
assert_eq!(child.read(read("visible")).await.unwrap().bytes, b"visible");
|
||||||
|
assert!(matches!(
|
||||||
|
child.read(read("escape")).await,
|
||||||
|
Err(WorkdirError::Denied(message))
|
||||||
|
if message.contains("provider-resolved delegated scope")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn write_and_overlapping_scope_admission_are_serialized() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
fs::create_dir_all(root.path().join("shared")).unwrap();
|
||||||
|
let (parent, mut entered, release) = blocking_session(root.path());
|
||||||
|
let writer = {
|
||||||
|
let parent = parent.clone();
|
||||||
|
tokio::spawn(async move { parent.write(write("shared/file", "written")).await })
|
||||||
|
};
|
||||||
|
entered.changed().await.unwrap();
|
||||||
|
assert!(*entered.borrow());
|
||||||
|
|
||||||
|
let mut admission = {
|
||||||
|
let parent = parent.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
parent
|
||||||
|
.scope(request("shared", WorkdirToolScopePermission::Write))
|
||||||
|
.await
|
||||||
|
})
|
||||||
|
};
|
||||||
assert!(
|
assert!(
|
||||||
result.is_err(),
|
tokio::time::timeout(std::time::Duration::from_millis(50), &mut admission)
|
||||||
"symlink write escaped provider scope: {result:?}"
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"scope admission must wait for the in-flight parent write"
|
||||||
);
|
);
|
||||||
assert!(!root.path().join("secret/new").exists());
|
|
||||||
|
release.notify_waiters();
|
||||||
|
writer.await.unwrap().unwrap();
|
||||||
|
let lease = tokio::time::timeout(std::time::Duration::from_secs(1), admission)
|
||||||
|
.await
|
||||||
|
.expect("scope admission should resume after write completion")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
drop(lease);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn read_rules_do_not_expand_child_write_lease_conflicts() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
fs::create_dir_all(root.path().join("leased")).unwrap();
|
||||||
|
fs::create_dir_all(root.path().join("other")).unwrap();
|
||||||
|
let parent = session(root.path());
|
||||||
|
let child = parent
|
||||||
|
.scope(WorkdirToolScope {
|
||||||
|
rules: vec![
|
||||||
|
WorkdirToolScopeRule {
|
||||||
|
target: fs_path("leased"),
|
||||||
|
permission: WorkdirToolScopePermission::Write,
|
||||||
|
recursive: true,
|
||||||
|
symlink_policy: SymlinkPolicy::Resolved,
|
||||||
|
},
|
||||||
|
WorkdirToolScopeRule {
|
||||||
|
target: FsPath::root(),
|
||||||
|
permission: WorkdirToolScopePermission::Read,
|
||||||
|
recursive: true,
|
||||||
|
symlink_policy: SymlinkPolicy::Resolved,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cwd: fs_path("leased"),
|
||||||
|
command: false,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
parent
|
||||||
|
.write(write("other/parent", "allowed"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let sibling = parent
|
||||||
|
.scope(request("other", WorkdirToolScopePermission::Write))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
sibling.write(write("sibling", "allowed")).await.unwrap();
|
||||||
|
drop(child);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn write_delegation_rejects_symlink_target_before_lease() {
|
async fn sibling_write_scopes_reject_distinct_aliases_to_same_resolved_target() {
|
||||||
use std::os::unix::fs::symlink;
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
let root = TempDir::new().unwrap();
|
let root = TempDir::new().unwrap();
|
||||||
fs::create_dir_all(root.path().join("granted")).unwrap();
|
fs::create_dir_all(root.path().join("target")).unwrap();
|
||||||
fs::create_dir_all(root.path().join("secret")).unwrap();
|
symlink("target", root.path().join("alias-a")).unwrap();
|
||||||
symlink("../secret", root.path().join("granted/outside")).unwrap();
|
symlink("target", root.path().join("alias-b")).unwrap();
|
||||||
let parent = session(root.path());
|
let parent = session(root.path());
|
||||||
|
let _first = parent
|
||||||
|
.scope(request("alias-a", WorkdirToolScopePermission::Write))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
parent
|
parent
|
||||||
.scope(request(
|
.scope(request("alias-b", WorkdirToolScopePermission::Write))
|
||||||
"granted/outside",
|
|
||||||
WorkdirToolScopePermission::Write
|
|
||||||
))
|
|
||||||
.await,
|
.await,
|
||||||
Err(WorkdirError::Denied(_))
|
Err(WorkdirError::Denied(message))
|
||||||
|
if message.contains("overlaps an active child scope after provider resolution")
|
||||||
));
|
));
|
||||||
parent
|
assert!(matches!(
|
||||||
.write(write("secret/parent", "still-authoritative"))
|
parent
|
||||||
|
.write(WriteRequest {
|
||||||
|
path: FsPath::new("target/from-parent").unwrap(),
|
||||||
|
content: b"blocked".to_vec(),
|
||||||
|
expected_hash: None,
|
||||||
|
})
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::Denied(message))
|
||||||
|
if message.contains("leased to child Workdir tools")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn nested_scope_cannot_expand_resolved_policy_to_logical() {
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
fs::create_dir_all(root.path().join("granted")).unwrap();
|
||||||
|
let parent = session(root.path());
|
||||||
|
let child = parent
|
||||||
|
.scope(request("granted", WorkdirToolScopePermission::Read))
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let mut expanded = request(".", WorkdirToolScopePermission::Read);
|
||||||
|
expanded.rules[0].symlink_policy = SymlinkPolicy::Logical;
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
child.scope(expanded).await,
|
||||||
|
Err(WorkdirError::Denied(message))
|
||||||
|
if message.contains("exceeds the parent tool scope")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn provider_scope_allows_read_through_its_logical_symlink_path() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
fs::create_dir_all(root.path().join("granted")).unwrap();
|
||||||
|
fs::create_dir_all(root.path().join("secret")).unwrap();
|
||||||
|
fs::write(root.path().join("secret/key"), "hidden").unwrap();
|
||||||
|
symlink("../secret/key", root.path().join("granted/link")).unwrap();
|
||||||
|
let parent = session(root.path());
|
||||||
|
let mut scope = request("granted", WorkdirToolScopePermission::Read);
|
||||||
|
scope.rules[0].symlink_policy = SymlinkPolicy::Logical;
|
||||||
|
let child = parent.scope(scope).await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(child.read(read("link")).await.unwrap().bytes, b"hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn provider_scope_allows_write_through_its_logical_symlink_path() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
fs::create_dir_all(root.path().join("granted")).unwrap();
|
||||||
|
fs::create_dir_all(root.path().join("secret")).unwrap();
|
||||||
|
symlink("../secret", root.path().join("granted/outside")).unwrap();
|
||||||
|
let parent = session(root.path());
|
||||||
|
let mut scope = request("granted", WorkdirToolScopePermission::Write);
|
||||||
|
scope.rules[0].symlink_policy = SymlinkPolicy::Logical;
|
||||||
|
let child = parent.scope(scope).await.unwrap();
|
||||||
|
|
||||||
|
child
|
||||||
|
.write(write("outside/new", "through-logical-path"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(root.path().join("secret/new")).unwrap(),
|
||||||
|
"through-logical-path"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn write_delegation_leases_logical_alias_and_resolved_target() {
|
||||||
|
use std::os::unix::fs::symlink;
|
||||||
|
|
||||||
|
let root = TempDir::new().unwrap();
|
||||||
|
fs::create_dir_all(root.path().join("granted")).unwrap();
|
||||||
|
fs::create_dir_all(root.path().join("secret")).unwrap();
|
||||||
|
symlink("../secret", root.path().join("granted/outside")).unwrap();
|
||||||
|
let parent = session(root.path());
|
||||||
|
|
||||||
|
let mut scope = request("granted/outside", WorkdirToolScopePermission::Write);
|
||||||
|
scope.rules[0].symlink_policy = SymlinkPolicy::Logical;
|
||||||
|
let child = parent.scope(scope).await.unwrap();
|
||||||
|
child
|
||||||
|
.write(write("from-child", "child-authoritative"))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
parent
|
||||||
|
.write(write("secret/parent", "must-be-blocked"))
|
||||||
|
.await,
|
||||||
|
Err(WorkdirError::Denied(message))
|
||||||
|
if message.contains("leased to child Workdir tools")
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
fs::read_to_string(root.path().join("secret/from-child")).unwrap(),
|
||||||
|
"child-authoritative"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ required-features = ["ws-server", "fs-store"]
|
|||||||
|
|
||||||
[features]
|
[features]
|
||||||
default = ["ws-server", "fs-store"]
|
default = ["ws-server", "fs-store"]
|
||||||
fs-store = []
|
fs-store = ["dep:fs4"]
|
||||||
http-server = ["dep:axum", "dep:tower", "dep:reqwest"]
|
http-server = ["dep:axum", "dep:tower", "dep:reqwest"]
|
||||||
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
|
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
|
||||||
|
|
||||||
@@ -29,6 +29,7 @@ axum = { workspace = true, optional = true }
|
|||||||
futures = { workspace = true, optional = true }
|
futures = { workspace = true, optional = true }
|
||||||
decodal.workspace = true
|
decodal.workspace = true
|
||||||
flow = { path = "../flow" }
|
flow = { path = "../flow" }
|
||||||
|
fs4 = { workspace = true, optional = true }
|
||||||
manifest.workspace = true
|
manifest.workspace = true
|
||||||
protocol.workspace = true
|
protocol.workspace = true
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
@@ -39,7 +40,7 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature
|
|||||||
ring.workspace = true
|
ring.workspace = true
|
||||||
tar.workspace = true
|
tar.workspace = true
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
|
tokio = { workspace = true, features = ["net", "process", "rt", "sync", "time"] }
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
toml.workspace = true
|
toml.workspace = true
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use base64::Engine;
|
|||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||||
use ring::rand::{SecureRandom, SystemRandom};
|
use ring::rand::{SecureRandom, SystemRandom};
|
||||||
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
|
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
@@ -9,8 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
|||||||
|
|
||||||
const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
|
const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
|
||||||
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
|
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
|
||||||
const TOKEN_PREFIX: &str = "yoi-cap-v1";
|
|
||||||
const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1.";
|
|
||||||
pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof";
|
pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof";
|
||||||
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
|
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
|
||||||
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
|
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
|
||||||
@@ -68,6 +67,74 @@ pub enum RuntimeAuthError {
|
|||||||
WrongMutationTarget,
|
WrongMutationTarget,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) struct SignedJsonToken<T> {
|
||||||
|
pub payload: String,
|
||||||
|
pub signature: Vec<u8>,
|
||||||
|
pub claims: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn sign_json_token<T: Serialize>(
|
||||||
|
token_prefix: &str,
|
||||||
|
signing_input_prefix: &str,
|
||||||
|
signing_key: &Ed25519KeyPair,
|
||||||
|
claims: &T,
|
||||||
|
) -> Result<String, RuntimeAuthError> {
|
||||||
|
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?);
|
||||||
|
let signing_input = format!("{signing_input_prefix}{payload}");
|
||||||
|
let signature = signing_key.sign(signing_input.as_bytes());
|
||||||
|
Ok(format!(
|
||||||
|
"{token_prefix}.{payload}.{}",
|
||||||
|
URL_SAFE_NO_PAD.encode(signature.as_ref())
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn decode_signed_json_token<T: DeserializeOwned>(
|
||||||
|
token: &str,
|
||||||
|
expected_prefix: &str,
|
||||||
|
) -> Result<SignedJsonToken<T>, RuntimeAuthError> {
|
||||||
|
let (prefix, payload, signature) = split_three_part_token(token)?;
|
||||||
|
if prefix != expected_prefix {
|
||||||
|
return Err(RuntimeAuthError::InvalidTokenFormat);
|
||||||
|
}
|
||||||
|
let signature = URL_SAFE_NO_PAD.decode(signature)?;
|
||||||
|
let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?;
|
||||||
|
Ok(SignedJsonToken {
|
||||||
|
payload: payload.to_string(),
|
||||||
|
signature,
|
||||||
|
claims,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn verify_signed_json_token(
|
||||||
|
signing_input_prefix: &str,
|
||||||
|
payload: &str,
|
||||||
|
signature: &[u8],
|
||||||
|
public_key: &str,
|
||||||
|
) -> Result<(), RuntimeAuthError> {
|
||||||
|
let public_key = decode_public_key(public_key)?;
|
||||||
|
let signing_input = format!("{signing_input_prefix}{payload}");
|
||||||
|
UnparsedPublicKey::new(&ED25519, public_key)
|
||||||
|
.verify(signing_input.as_bytes(), signature)
|
||||||
|
.map_err(|_| RuntimeAuthError::InvalidSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn split_three_part_token(token: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
|
||||||
|
let mut parts = token.split('.');
|
||||||
|
let prefix = parts.next().unwrap_or_default();
|
||||||
|
let payload = parts.next().unwrap_or_default();
|
||||||
|
let signature = parts.next().unwrap_or_default();
|
||||||
|
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
|
||||||
|
return Err(RuntimeAuthError::InvalidTokenFormat);
|
||||||
|
}
|
||||||
|
Ok((prefix, payload, signature))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_request_body_digest(value: &str) -> bool {
|
||||||
|
URL_SAFE_NO_PAD
|
||||||
|
.decode(value)
|
||||||
|
.is_ok_and(|decoded| decoded.len() == 32 && URL_SAFE_NO_PAD.encode(decoded) == value)
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeIdentityMaterial {
|
pub struct RuntimeIdentityMaterial {
|
||||||
pub identity_id: String,
|
pub identity_id: String,
|
||||||
@@ -95,21 +162,6 @@ impl RuntimeIdentityMaterial {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct TrustedServerKey {
|
|
||||||
pub server_id: String,
|
|
||||||
pub public_key: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub display_name: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct RuntimeHttpAuthConfig {
|
|
||||||
pub runtime_id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub trusted_servers: Vec<TrustedServerKey>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeAuthContext {
|
pub struct RuntimeAuthContext {
|
||||||
pub server_id: String,
|
pub server_id: String,
|
||||||
@@ -119,122 +171,6 @@ pub struct RuntimeAuthContext {
|
|||||||
pub expires_at: u64,
|
pub expires_at: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
pub struct CapabilityClaims {
|
|
||||||
pub iss: String,
|
|
||||||
pub aud: String,
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub permissions: Vec<String>,
|
|
||||||
pub exp: u64,
|
|
||||||
pub jti: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
||||||
pub struct CapabilityTokenSigner {
|
|
||||||
server_id: String,
|
|
||||||
private_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CapabilityTokenSigner {
|
|
||||||
pub fn new(server_id: impl Into<String>, private_key: impl Into<String>) -> Self {
|
|
||||||
Self {
|
|
||||||
server_id: server_id.into(),
|
|
||||||
private_key: private_key.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn server_id(&self) -> &str {
|
|
||||||
&self.server_id
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn sign(&self, claims: &CapabilityClaims) -> Result<String, RuntimeAuthError> {
|
|
||||||
if claims.iss != self.server_id {
|
|
||||||
return Err(RuntimeAuthError::UnknownIssuer(claims.iss.clone()));
|
|
||||||
}
|
|
||||||
let private = decode_private_key(&self.private_key)?;
|
|
||||||
let pair = Ed25519KeyPair::from_pkcs8(&private)
|
|
||||||
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
|
|
||||||
let payload = serde_json::to_vec(claims)?;
|
|
||||||
let payload = URL_SAFE_NO_PAD.encode(payload);
|
|
||||||
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
|
|
||||||
let signature = pair.sign(signing_input.as_bytes());
|
|
||||||
Ok(format!(
|
|
||||||
"{TOKEN_PREFIX}.{payload}.{}",
|
|
||||||
URL_SAFE_NO_PAD.encode(signature.as_ref())
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn capability_claims(
|
|
||||||
server_id: impl Into<String>,
|
|
||||||
runtime_id: impl Into<String>,
|
|
||||||
workspace_id: impl Into<String>,
|
|
||||||
permissions: Vec<String>,
|
|
||||||
ttl_seconds: u64,
|
|
||||||
) -> Result<CapabilityClaims, RuntimeAuthError> {
|
|
||||||
let exp = unix_now_seconds().saturating_add(ttl_seconds);
|
|
||||||
Ok(CapabilityClaims {
|
|
||||||
iss: server_id.into(),
|
|
||||||
aud: runtime_id.into(),
|
|
||||||
workspace_id: workspace_id.into(),
|
|
||||||
permissions,
|
|
||||||
exp,
|
|
||||||
jti: new_token_id()?,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn verify_capability_token(
|
|
||||||
config: &RuntimeHttpAuthConfig,
|
|
||||||
token: &str,
|
|
||||||
required_permission: Option<&str>,
|
|
||||||
now_seconds: u64,
|
|
||||||
) -> Result<RuntimeAuthContext, RuntimeAuthError> {
|
|
||||||
let (payload, signature) = split_token(token)?;
|
|
||||||
let claims_json = URL_SAFE_NO_PAD.decode(payload)?;
|
|
||||||
let claims: CapabilityClaims = serde_json::from_slice(&claims_json)?;
|
|
||||||
let Some(server) = config
|
|
||||||
.trusted_servers
|
|
||||||
.iter()
|
|
||||||
.find(|server| server.server_id == claims.iss)
|
|
||||||
else {
|
|
||||||
return Err(RuntimeAuthError::UnknownIssuer(claims.iss));
|
|
||||||
};
|
|
||||||
let public_key = decode_public_key(&server.public_key)?;
|
|
||||||
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
|
|
||||||
UnparsedPublicKey::new(&ED25519, public_key)
|
|
||||||
.verify(signing_input.as_bytes(), &signature)
|
|
||||||
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
|
|
||||||
|
|
||||||
if claims.aud != config.runtime_id {
|
|
||||||
return Err(RuntimeAuthError::WrongAudience {
|
|
||||||
expected: config.runtime_id.clone(),
|
|
||||||
actual: claims.aud,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if claims.exp < now_seconds {
|
|
||||||
return Err(RuntimeAuthError::Expired);
|
|
||||||
}
|
|
||||||
if claims.workspace_id.trim().is_empty() {
|
|
||||||
return Err(RuntimeAuthError::MissingWorkspaceScope);
|
|
||||||
}
|
|
||||||
if let Some(required) = required_permission {
|
|
||||||
if !claims
|
|
||||||
.permissions
|
|
||||||
.iter()
|
|
||||||
.any(|permission| permission == required)
|
|
||||||
{
|
|
||||||
return Err(RuntimeAuthError::MissingPermission(required.to_string()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(RuntimeAuthContext {
|
|
||||||
server_id: claims.iss,
|
|
||||||
workspace_id: claims.workspace_id,
|
|
||||||
permissions: claims.permissions,
|
|
||||||
token_id: claims.jti,
|
|
||||||
expires_at: claims.exp,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RuntimeRequestSourceClaims {
|
pub struct RuntimeRequestSourceClaims {
|
||||||
pub iss: String,
|
pub iss: String,
|
||||||
@@ -323,28 +259,22 @@ impl RuntimeRequestSourceSigner {
|
|||||||
exp: now_unix.saturating_add(ttl_seconds),
|
exp: now_unix.saturating_add(ttl_seconds),
|
||||||
jti: new_token_id()?,
|
jti: new_token_id()?,
|
||||||
};
|
};
|
||||||
let payload = serde_json::to_vec(&claims)?;
|
|
||||||
let payload = URL_SAFE_NO_PAD.encode(payload);
|
|
||||||
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
|
|
||||||
let private = decode_private_key(&self.private_key)?;
|
let private = decode_private_key(&self.private_key)?;
|
||||||
let key_pair = Ed25519KeyPair::from_pkcs8(&private)
|
let key_pair = Ed25519KeyPair::from_pkcs8(&private)
|
||||||
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
|
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
|
||||||
let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref());
|
sign_json_token(
|
||||||
Ok(format!(
|
RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
|
||||||
"{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}"
|
RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
|
||||||
))
|
&key_pair,
|
||||||
|
&claims,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decode_runtime_request_source_claims(
|
pub fn decode_runtime_request_source_claims(
|
||||||
proof: &str,
|
proof: &str,
|
||||||
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
|
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
|
||||||
let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?;
|
Ok(decode_signed_json_token(proof, RUNTIME_REQUEST_SOURCE_PROOF_PREFIX)?.claims)
|
||||||
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
|
|
||||||
return Err(RuntimeAuthError::InvalidTokenFormat);
|
|
||||||
}
|
|
||||||
let payload = URL_SAFE_NO_PAD.decode(payload)?;
|
|
||||||
serde_json::from_slice(&payload).map_err(RuntimeAuthError::from)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify_runtime_request_source(
|
pub fn verify_runtime_request_source(
|
||||||
@@ -352,17 +282,17 @@ pub fn verify_runtime_request_source(
|
|||||||
public_key: &str,
|
public_key: &str,
|
||||||
expected: &RuntimeRequestSourceExpectation<'_>,
|
expected: &RuntimeRequestSourceExpectation<'_>,
|
||||||
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
|
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
|
||||||
let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?;
|
let signed = decode_signed_json_token::<RuntimeRequestSourceClaims>(
|
||||||
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
|
proof,
|
||||||
return Err(RuntimeAuthError::InvalidTokenFormat);
|
RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
|
||||||
}
|
)?;
|
||||||
let signature = URL_SAFE_NO_PAD.decode(signature)?;
|
verify_signed_json_token(
|
||||||
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
|
RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
|
||||||
let public_key = decode_public_key(public_key)?;
|
&signed.payload,
|
||||||
UnparsedPublicKey::new(&ED25519, public_key)
|
&signed.signature,
|
||||||
.verify(signing_input.as_bytes(), &signature)
|
public_key,
|
||||||
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
|
)?;
|
||||||
let claims = decode_runtime_request_source_claims(proof)?;
|
let claims = signed.claims;
|
||||||
if claims.iss != expected.identity_id
|
if claims.iss != expected.identity_id
|
||||||
|| claims.aud != expected.audience
|
|| claims.aud != expected.audience
|
||||||
|| claims.workspace_id != expected.workspace_id
|
|| claims.workspace_id != expected.workspace_id
|
||||||
@@ -380,17 +310,6 @@ pub fn verify_runtime_request_source(
|
|||||||
Ok(claims)
|
Ok(claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
|
|
||||||
let mut parts = proof.split('.');
|
|
||||||
let prefix = parts.next().unwrap_or_default();
|
|
||||||
let payload = parts.next().unwrap_or_default();
|
|
||||||
let signature = parts.next().unwrap_or_default();
|
|
||||||
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
|
|
||||||
return Err(RuntimeAuthError::InvalidTokenFormat);
|
|
||||||
}
|
|
||||||
Ok((prefix, payload, signature))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkerMutationSourceClaims {
|
pub struct WorkerMutationSourceClaims {
|
||||||
pub iss: String,
|
pub iss: String,
|
||||||
@@ -586,16 +505,6 @@ fn split_worker_mutation_source_proof(token: &str) -> Result<(&str, Vec<u8>), Ru
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn split_token(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
|
|
||||||
let mut parts = token.split('.');
|
|
||||||
match (parts.next(), parts.next(), parts.next(), parts.next()) {
|
|
||||||
(Some(prefix), Some(payload), Some(signature), None) if prefix == TOKEN_PREFIX => {
|
|
||||||
Ok((payload, URL_SAFE_NO_PAD.decode(signature)?))
|
|
||||||
}
|
|
||||||
_ => Err(RuntimeAuthError::InvalidTokenFormat),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn encode_public_key(bytes: &[u8]) -> String {
|
pub fn encode_public_key(bytes: &[u8]) -> String {
|
||||||
format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
|
format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
|
||||||
}
|
}
|
||||||
@@ -851,46 +760,4 @@ mod tests {
|
|||||||
Err(RuntimeAuthError::Expired)
|
Err(RuntimeAuthError::Expired)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn capability_token_verifies_signature_audience_expiry_and_permission() {
|
|
||||||
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
|
|
||||||
let signer = CapabilityTokenSigner::new(&server.identity_id, &server.private_key);
|
|
||||||
let claims = CapabilityClaims {
|
|
||||||
iss: "server-main".to_string(),
|
|
||||||
aud: "runtime-main".to_string(),
|
|
||||||
workspace_id: "workspace-a".to_string(),
|
|
||||||
permissions: vec!["workers:list".to_string()],
|
|
||||||
exp: 100,
|
|
||||||
jti: "token-1".to_string(),
|
|
||||||
};
|
|
||||||
let token = signer.sign(&claims).unwrap();
|
|
||||||
let auth = RuntimeHttpAuthConfig {
|
|
||||||
runtime_id: "runtime-main".to_string(),
|
|
||||||
trusted_servers: vec![TrustedServerKey {
|
|
||||||
server_id: "server-main".to_string(),
|
|
||||||
public_key: server.public_key.clone(),
|
|
||||||
display_name: None,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
|
|
||||||
let context = verify_capability_token(&auth, &token, Some("workers:list"), 99).unwrap();
|
|
||||||
assert_eq!(context.workspace_id, "workspace-a");
|
|
||||||
assert!(matches!(
|
|
||||||
verify_capability_token(&auth, &token, Some("workers:create"), 99),
|
|
||||||
Err(RuntimeAuthError::MissingPermission(permission)) if permission == "workers:create"
|
|
||||||
));
|
|
||||||
assert!(matches!(
|
|
||||||
verify_capability_token(&auth, &token, Some("workers:list"), 101),
|
|
||||||
Err(RuntimeAuthError::Expired)
|
|
||||||
));
|
|
||||||
let wrong_audience = RuntimeHttpAuthConfig {
|
|
||||||
runtime_id: "other-runtime".to_string(),
|
|
||||||
trusted_servers: auth.trusted_servers.clone(),
|
|
||||||
};
|
|
||||||
assert!(matches!(
|
|
||||||
verify_capability_token(&wrong_audience, &token, Some("workers:list"), 99),
|
|
||||||
Err(RuntimeAuthError::WrongAudience { .. })
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,9 +119,16 @@ impl std::fmt::Debug for SensitiveString {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RepositorySshMaterializationAccess {
|
pub struct RepositorySshCredentialCandidate {
|
||||||
pub credential_id: String,
|
pub credential_id: String,
|
||||||
pub credential_revision: u64,
|
pub credential_revision: u64,
|
||||||
|
#[serde(skip, default)]
|
||||||
|
pub private_key: SensitiveString,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RepositorySshMaterializationAccess {
|
||||||
|
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
|
||||||
pub host_trust_id: String,
|
pub host_trust_id: String,
|
||||||
pub host_trust_revision: u64,
|
pub host_trust_revision: u64,
|
||||||
pub access: workspace_api::RepositoryAccessMode,
|
pub access: workspace_api::RepositoryAccessMode,
|
||||||
@@ -131,8 +138,6 @@ pub struct RepositorySshMaterializationAccess {
|
|||||||
pub repository_uri: String,
|
pub repository_uri: String,
|
||||||
pub secret_resource: crate::resource::BackendResourceHandle,
|
pub secret_resource: crate::resource::BackendResourceHandle,
|
||||||
#[serde(skip, default)]
|
#[serde(skip, default)]
|
||||||
pub private_key: SensitiveString,
|
|
||||||
#[serde(skip, default)]
|
|
||||||
pub known_hosts_entry: SensitiveString,
|
pub known_hosts_entry: SensitiveString,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,8 +148,6 @@ pub struct RepositoryMaterializationContext {
|
|||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
pub config_revision: u64,
|
pub config_revision: u64,
|
||||||
pub config_projection_digest: String,
|
pub config_projection_digest: String,
|
||||||
#[serde(default)]
|
|
||||||
pub cache_generation: u64,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub ssh: Option<RepositorySshMaterializationAccess>,
|
pub ssh: Option<RepositorySshMaterializationAccess>,
|
||||||
}
|
}
|
||||||
@@ -263,11 +266,11 @@ pub struct CreateWorkerRequest {
|
|||||||
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
pub memory_settings: Option<manifest::WorkspaceMemorySettingsSnapshot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Worker lifecycle status for the in-memory embedded runtime.
|
/// Last persisted Worker lifecycle status.
|
||||||
///
|
///
|
||||||
/// Run termination details are carried separately by the Worker protocol. In
|
/// This is not proof that the current Runtime process holds a live execution handle. Run
|
||||||
/// particular, cancellation returns a Worker to `Idle`; it is not a lifecycle
|
/// termination details remain separate Worker protocol state; in particular, cancellation
|
||||||
/// state of its own.
|
/// returns a Worker to `Idle` and is not a lifecycle state of its own.
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkerStatus {
|
pub enum WorkerStatus {
|
||||||
@@ -290,12 +293,17 @@ pub(crate) enum WorkerRestoreIntent {
|
|||||||
Explicit,
|
Explicit,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lightweight catalog row.
|
/// Lightweight persisted Worker identity projection.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkerSummary {
|
pub struct WorkerSummary {
|
||||||
pub worker_ref: WorkerRef,
|
pub worker_ref: WorkerRef,
|
||||||
pub worker_id: WorkerId,
|
pub worker_id: WorkerId,
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
|
/// Creation timestamp in Unix epoch milliseconds for records created on this schema.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created_at_ms: Option<u64>,
|
||||||
|
/// Whether the persisted execution metadata was valid when this identity was loaded.
|
||||||
|
pub execution_metadata_available: bool,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@@ -310,12 +318,17 @@ pub struct WorkerSummary {
|
|||||||
pub config_bundle: Option<ConfigBundleRef>,
|
pub config_bundle: Option<ConfigBundleRef>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Full Worker catalog/lifecycle detail.
|
/// Full persisted Worker identity and lifecycle detail.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkerDetail {
|
pub struct WorkerDetail {
|
||||||
pub worker_ref: WorkerRef,
|
pub worker_ref: WorkerRef,
|
||||||
pub worker_id: WorkerId,
|
pub worker_id: WorkerId,
|
||||||
pub status: WorkerStatus,
|
pub status: WorkerStatus,
|
||||||
|
/// Creation timestamp in Unix epoch milliseconds for records created on this schema.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created_at_ms: Option<u64>,
|
||||||
|
/// Whether the persisted execution metadata was valid when this identity was loaded.
|
||||||
|
pub execution_metadata_available: bool,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
pub worker_state: Option<protocol::WorkerStateSnapshot>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ pub enum RuntimeError {
|
|||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[error("worker {worker_id} metadata deletion failed: {message}")]
|
||||||
|
WorkerDeletePersistenceFailed {
|
||||||
|
worker_id: WorkerId,
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
|
|
||||||
#[error("worker creation has no execution backend: {message}")]
|
#[error("worker creation has no execution backend: {message}")]
|
||||||
ExecutionBackendUnavailable { message: String },
|
ExecutionBackendUnavailable { message: String },
|
||||||
|
|
||||||
@@ -79,6 +85,9 @@ pub enum RuntimeError {
|
|||||||
name: String,
|
name: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[error("Runtime store is already owned by another process")]
|
||||||
|
RuntimeStoreAlreadyOpen { path: PathBuf },
|
||||||
|
|
||||||
#[error("runtime store {operation} failed at {}: {source}", path.display())]
|
#[error("runtime store {operation} failed at {}: {source}", path.display())]
|
||||||
StoreIo {
|
StoreIo {
|
||||||
operation: &'static str,
|
operation: &'static str,
|
||||||
|
|||||||
@@ -243,8 +243,6 @@ impl fmt::Debug for WorkerExecutionContext {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct WorkerExecutionSpawnRequest {
|
pub struct WorkerExecutionSpawnRequest {
|
||||||
pub worker_ref: WorkerRef,
|
pub worker_ref: WorkerRef,
|
||||||
/// Monotonic execution generation reserved durably before launch.
|
|
||||||
pub run_generation: u64,
|
|
||||||
pub request: crate::catalog::CreateWorkerRequest,
|
pub request: crate::catalog::CreateWorkerRequest,
|
||||||
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
|
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
|
||||||
pub context: WorkerExecutionContext,
|
pub context: WorkerExecutionContext,
|
||||||
@@ -256,8 +254,6 @@ pub struct WorkerExecutionSpawnRequest {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct WorkerExecutionRestoreRequest {
|
pub struct WorkerExecutionRestoreRequest {
|
||||||
pub worker_ref: WorkerRef,
|
pub worker_ref: WorkerRef,
|
||||||
/// Monotonic execution generation reserved durably before restore.
|
|
||||||
pub run_generation: u64,
|
|
||||||
pub request: crate::catalog::CreateWorkerRequest,
|
pub request: crate::catalog::CreateWorkerRequest,
|
||||||
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
|
pub workspace_scope: Option<crate::runtime::RuntimeWorkspaceScope>,
|
||||||
pub context: WorkerExecutionContext,
|
pub context: WorkerExecutionContext,
|
||||||
@@ -287,6 +283,7 @@ pub enum WorkspaceConfigFetchResult {
|
|||||||
pub enum WorkerExecutionSpawnResult {
|
pub enum WorkerExecutionSpawnResult {
|
||||||
Connected {
|
Connected {
|
||||||
handle: WorkerExecutionHandle,
|
handle: WorkerExecutionHandle,
|
||||||
|
worker_state: protocol::WorkerStateSnapshot,
|
||||||
working_directory: Option<WorkingDirectoryStatus>,
|
working_directory: Option<WorkingDirectoryStatus>,
|
||||||
},
|
},
|
||||||
Rejected(WorkerExecutionResult),
|
Rejected(WorkerExecutionResult),
|
||||||
@@ -296,10 +293,12 @@ pub enum WorkerExecutionSpawnResult {
|
|||||||
impl WorkerExecutionSpawnResult {
|
impl WorkerExecutionSpawnResult {
|
||||||
pub fn connected(
|
pub fn connected(
|
||||||
handle: WorkerExecutionHandle,
|
handle: WorkerExecutionHandle,
|
||||||
|
worker_state: protocol::WorkerStateSnapshot,
|
||||||
working_directory: Option<WorkingDirectoryStatus>,
|
working_directory: Option<WorkingDirectoryStatus>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self::Connected {
|
Self::Connected {
|
||||||
handle,
|
handle,
|
||||||
|
worker_state,
|
||||||
working_directory,
|
working_directory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -25,9 +25,13 @@ pub mod resource;
|
|||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
pub mod retention;
|
pub mod retention;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
|
pub mod ssh_host_key_probe;
|
||||||
pub mod worker_backend;
|
pub mod worker_backend;
|
||||||
pub mod worker_source;
|
pub mod worker_source;
|
||||||
pub mod working_directory;
|
pub mod working_directory;
|
||||||
|
pub mod workspace_issuer;
|
||||||
|
#[cfg(feature = "http-server")]
|
||||||
|
pub mod workspace_request;
|
||||||
|
|
||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
|
||||||
|
|||||||
+762
-251
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,7 @@
|
|||||||
use crate::auth::{
|
use crate::auth::BACKEND_RESOURCE_FETCH_PERMISSION;
|
||||||
BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
|
|
||||||
RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds,
|
|
||||||
};
|
|
||||||
use crate::identity::WorkerId;
|
use crate::identity::WorkerId;
|
||||||
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
|
use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex};
|
||||||
|
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -13,16 +11,41 @@ pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str =
|
|||||||
"application/vnd.yoi.repository-ssh-access+json";
|
"application/vnd.yoi.repository-ssh-access+json";
|
||||||
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
|
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
|
||||||
pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024;
|
pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024;
|
||||||
|
pub const DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT: std::time::Duration =
|
||||||
|
std::time::Duration::from_secs(15);
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct RepositorySshAccessSecretCandidate {
|
||||||
|
pub credential_id: String,
|
||||||
|
pub credential_revision: u64,
|
||||||
|
pub private_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for RepositorySshAccessSecretCandidate {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
zeroize::Zeroize::zeroize(&mut self.private_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for RepositorySshAccessSecretCandidate {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("RepositorySshAccessSecretCandidate")
|
||||||
|
.field("credential_id", &self.credential_id)
|
||||||
|
.field("credential_revision", &self.credential_revision)
|
||||||
|
.field("private_key", &"[REDACTED]")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
pub struct RepositorySshAccessSecret {
|
pub struct RepositorySshAccessSecret {
|
||||||
pub private_key: String,
|
pub credential_candidates: Vec<RepositorySshAccessSecretCandidate>,
|
||||||
pub known_hosts_entry: String,
|
pub known_hosts_entry: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for RepositorySshAccessSecret {
|
impl Drop for RepositorySshAccessSecret {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
zeroize::Zeroize::zeroize(&mut self.private_key);
|
|
||||||
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry);
|
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -31,7 +54,7 @@ impl std::fmt::Debug for RepositorySshAccessSecret {
|
|||||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
formatter
|
formatter
|
||||||
.debug_struct("RepositorySshAccessSecret")
|
.debug_struct("RepositorySshAccessSecret")
|
||||||
.field("private_key", &"[REDACTED]")
|
.field("credential_candidates", &self.credential_candidates)
|
||||||
.field("known_hosts_entry", &"[REDACTED]")
|
.field("known_hosts_entry", &"[REDACTED]")
|
||||||
.finish()
|
.finish()
|
||||||
}
|
}
|
||||||
@@ -142,6 +165,8 @@ pub enum BackendResourceError {
|
|||||||
Oversized { max_bytes: u64, actual_bytes: u64 },
|
Oversized { max_bytes: u64, actual_bytes: u64 },
|
||||||
#[error("backend resource content type mismatch: expected {expected}, got {actual}")]
|
#[error("backend resource content type mismatch: expected {expected}, got {actual}")]
|
||||||
ContentTypeMismatch { expected: String, actual: String },
|
ContentTypeMismatch { expected: String, actual: String },
|
||||||
|
#[error("backend resource fetch timed out")]
|
||||||
|
Timeout,
|
||||||
#[error("backend resource transport failed: {message}")]
|
#[error("backend resource transport failed: {message}")]
|
||||||
Transport { message: String },
|
Transport { message: String },
|
||||||
#[error("backend resource response is invalid: {message}")]
|
#[error("backend resource response is invalid: {message}")]
|
||||||
@@ -161,9 +186,8 @@ pub trait BackendResourceClient: Send + Sync + 'static {
|
|||||||
pub struct HttpBackendResourceClient {
|
pub struct HttpBackendResourceClient {
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
bearer_token: Option<String>,
|
bearer_token: Option<String>,
|
||||||
request_source_signer: Option<RuntimeRequestSourceSigner>,
|
workspace_request_client: Option<RuntimeWorkspaceRequestClient>,
|
||||||
request_source_audience: Option<String>,
|
request_timeout: std::time::Duration,
|
||||||
client: reqwest::Client,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "http-server")]
|
#[cfg(feature = "http-server")]
|
||||||
@@ -172,19 +196,18 @@ impl HttpBackendResourceClient {
|
|||||||
Self {
|
Self {
|
||||||
endpoint: endpoint.into(),
|
endpoint: endpoint.into(),
|
||||||
bearer_token,
|
bearer_token,
|
||||||
request_source_signer: None,
|
workspace_request_client: None,
|
||||||
request_source_audience: None,
|
request_timeout: DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT,
|
||||||
client: reqwest::Client::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_runtime_request_source(
|
pub fn with_request_timeout(mut self, timeout: std::time::Duration) -> Self {
|
||||||
mut self,
|
self.request_timeout = timeout;
|
||||||
identity: &RuntimeIdentityMaterial,
|
self
|
||||||
audience: impl Into<String>,
|
}
|
||||||
) -> Self {
|
|
||||||
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity));
|
pub fn with_workspace_request_client(mut self, client: RuntimeWorkspaceRequestClient) -> Self {
|
||||||
self.request_source_audience = Some(audience.into());
|
self.workspace_request_client = Some(client);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -206,55 +229,73 @@ impl BackendResourceClient for HttpBackendResourceClient {
|
|||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
let mut builder = self
|
let client = self.workspace_request_client.as_ref().ok_or_else(|| {
|
||||||
.client
|
BackendResourceError::Unauthorized {
|
||||||
.post(endpoint.clone())
|
message: "Workspace request client is unavailable".to_string(),
|
||||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
}
|
||||||
.body(body.clone());
|
})?;
|
||||||
if let Some(signer) = self.request_source_signer.as_ref() {
|
if client.workspace_id() != request.handle.workspace_id {
|
||||||
let audience = self.request_source_audience.as_deref().ok_or_else(|| {
|
return Err(BackendResourceError::Unauthorized {
|
||||||
BackendResourceError::Unauthorized {
|
message: "Workspace request client does not match the resource workspace"
|
||||||
message: "Runtime request proof audience is unavailable".to_owned(),
|
.to_string(),
|
||||||
}
|
});
|
||||||
})?;
|
}
|
||||||
let proof = signer
|
let base_url = client.base_url().trim_end_matches('/');
|
||||||
.issue(
|
let endpoint_text = endpoint.as_str();
|
||||||
audience,
|
let endpoint_suffix = endpoint_text.strip_prefix(base_url).ok_or_else(|| {
|
||||||
&request.handle.workspace_id,
|
BackendResourceError::Unauthorized {
|
||||||
None,
|
message: "Workspace resource endpoint does not match its request client"
|
||||||
BACKEND_RESOURCE_FETCH_PERMISSION,
|
.to_string(),
|
||||||
"POST",
|
}
|
||||||
endpoint.path(),
|
})?;
|
||||||
&body,
|
if !endpoint_suffix.starts_with('/') {
|
||||||
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
return Err(BackendResourceError::Unauthorized {
|
||||||
30,
|
message: "Workspace resource endpoint does not match its request client"
|
||||||
)
|
.to_string(),
|
||||||
.map_err(|error| BackendResourceError::Unauthorized {
|
});
|
||||||
|
}
|
||||||
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
|
headers.insert(
|
||||||
|
reqwest::header::CONTENT_TYPE,
|
||||||
|
reqwest::header::HeaderValue::from_static("application/json"),
|
||||||
|
);
|
||||||
|
if let Some(token) = self.bearer_token.as_deref() {
|
||||||
|
let value = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
|
||||||
|
.map_err(|error| BackendResourceError::Transport {
|
||||||
message: error.to_string(),
|
message: error.to_string(),
|
||||||
})?;
|
})?;
|
||||||
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
|
headers.insert(reqwest::header::AUTHORIZATION, value);
|
||||||
}
|
}
|
||||||
let builder = if let Some(token) = self.bearer_token.as_deref() {
|
let response = client
|
||||||
builder.bearer_auth(token)
|
.execute(RuntimeWorkspaceRequest {
|
||||||
} else {
|
method: reqwest::Method::POST,
|
||||||
builder
|
path_and_query: endpoint_suffix.to_string(),
|
||||||
};
|
body,
|
||||||
let response = builder
|
headers,
|
||||||
.send()
|
permission: BACKEND_RESOURCE_FETCH_PERMISSION.to_string(),
|
||||||
|
worker_id: None,
|
||||||
|
timeout: Some(self.request_timeout),
|
||||||
|
max_response_bytes: 8 * 1024 * 1024,
|
||||||
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|err| BackendResourceError::Transport {
|
.map_err(|error| {
|
||||||
message: err.to_string(),
|
if error.is_timeout() {
|
||||||
|
BackendResourceError::Timeout
|
||||||
|
} else {
|
||||||
|
BackendResourceError::Transport {
|
||||||
|
message: error.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
})?;
|
})?;
|
||||||
if response.status().is_success() {
|
if response.status.is_success() {
|
||||||
response
|
serde_json::from_slice::<BackendResourceFetchResponse>(&response.body).map_err(|err| {
|
||||||
.json::<BackendResourceFetchResponse>()
|
BackendResourceError::InvalidResponse {
|
||||||
.await
|
|
||||||
.map_err(|err| BackendResourceError::InvalidResponse {
|
|
||||||
message: err.to_string(),
|
message: err.to_string(),
|
||||||
})
|
}
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
let status = response.status();
|
let status = response.status;
|
||||||
match response.json::<BackendResourceError>().await {
|
match serde_json::from_slice::<BackendResourceError>(&response.body) {
|
||||||
Ok(error) => Err(error),
|
Ok(error) => Err(error),
|
||||||
Err(err) => Err(BackendResourceError::Transport {
|
Err(err) => Err(BackendResourceError::Transport {
|
||||||
message: format!("backend resource fetch failed with HTTP {status}: {err}"),
|
message: format!("backend resource fetch failed with HTTP {status}: {err}"),
|
||||||
@@ -345,6 +386,7 @@ pub fn validate_resource_handle_text(label: &str, value: &str) -> Result<(), Str
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::auth::RuntimeIdentityMaterial;
|
||||||
use crate::profile_archive::ProfileSourceGraphSummary;
|
use crate::profile_archive::ProfileSourceGraphSummary;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
@@ -382,6 +424,61 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "http-server")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn http_backend_resource_fetch_has_a_bounded_timeout() {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let address = listener.local_addr().unwrap();
|
||||||
|
let server = tokio::spawn(async move {
|
||||||
|
let (stream, _) = listener.accept().await.unwrap();
|
||||||
|
futures::future::pending::<()>().await;
|
||||||
|
drop(stream);
|
||||||
|
});
|
||||||
|
let base_url = format!("http://{address}");
|
||||||
|
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
||||||
|
let handle = handle_for(b"archive-bytes");
|
||||||
|
let client = HttpBackendResourceClient::new(format!("{base_url}/fetch"), None)
|
||||||
|
.with_request_timeout(std::time::Duration::from_millis(25))
|
||||||
|
.with_workspace_request_client(
|
||||||
|
RuntimeWorkspaceRequestClient::new(
|
||||||
|
"workspace-test",
|
||||||
|
base_url.clone(),
|
||||||
|
"runtime-test",
|
||||||
|
)
|
||||||
|
.with_runtime_request_source(&identity, base_url),
|
||||||
|
);
|
||||||
|
|
||||||
|
let error = client
|
||||||
|
.fetch_resource(BackendResourceFetchRequest {
|
||||||
|
audit_correlation_id: handle.audit_correlation_id.clone(),
|
||||||
|
handle,
|
||||||
|
runtime_id: "runtime-test".to_string(),
|
||||||
|
worker_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
server.abort();
|
||||||
|
assert_eq!(error, BackendResourceError::Timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repository_ssh_access_secret_debug_redacts_all_secret_values() {
|
||||||
|
let secret = RepositorySshAccessSecret {
|
||||||
|
credential_candidates: vec![RepositorySshAccessSecretCandidate {
|
||||||
|
credential_id: "credential-1".to_string(),
|
||||||
|
credential_revision: 2,
|
||||||
|
private_key: "PRIVATE KEY secret bytes".to_string(),
|
||||||
|
}],
|
||||||
|
known_hosts_entry: "host key secret bytes".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let debug = format!("{secret:?}");
|
||||||
|
assert!(debug.contains("credential-1"));
|
||||||
|
assert!(!debug.contains("secret bytes"));
|
||||||
|
assert_eq!(debug.matches("[REDACTED]").count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn response_verification_detects_digest_mismatch() {
|
fn response_verification_detects_digest_mismatch() {
|
||||||
let bytes = b"archive-bytes";
|
let bytes = b"archive-bytes";
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ pub struct WorkerRetentionInventory {
|
|||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub worker_id: WorkerId,
|
pub worker_id: WorkerId,
|
||||||
pub run_generation: u64,
|
|
||||||
pub session_id: Option<String>,
|
pub session_id: Option<String>,
|
||||||
pub segment_ids: Vec<String>,
|
pub segment_ids: Vec<String>,
|
||||||
pub session_bytes: u64,
|
pub session_bytes: u64,
|
||||||
@@ -118,7 +117,6 @@ pub struct WorkerRetentionExecutionRequest {
|
|||||||
pub source_runtime_id: String,
|
pub source_runtime_id: String,
|
||||||
pub worker_id: WorkerId,
|
pub worker_id: WorkerId,
|
||||||
pub expected_worker_revision: String,
|
pub expected_worker_revision: String,
|
||||||
pub expected_run_generation: u64,
|
|
||||||
pub source_created_at: String,
|
pub source_created_at: String,
|
||||||
pub removed_at: String,
|
pub removed_at: String,
|
||||||
pub effective_profile: Option<String>,
|
pub effective_profile: Option<String>,
|
||||||
@@ -171,7 +169,6 @@ pub(crate) trait WorkerRetentionProvider: Send + Sync {
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
worker_id: WorkerId,
|
worker_id: WorkerId,
|
||||||
run_generation: u64,
|
|
||||||
) -> Result<WorkerRetentionInventory, RuntimeError>;
|
) -> Result<WorkerRetentionInventory, RuntimeError>;
|
||||||
|
|
||||||
fn execute(
|
fn execute(
|
||||||
@@ -283,7 +280,7 @@ impl FsWorkerRetentionProvider {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let worker_dir = self.worker_dir(worker_id);
|
let worker_dir = self.worker_dir(worker_id);
|
||||||
let snapshot: WorkerGenerationSnapshot = match read_json(
|
let snapshot: WorkerAggregateSnapshot = match read_json(
|
||||||
&worker_dir.join("worker.json"),
|
&worker_dir.join("worker.json"),
|
||||||
"scan Worker retention inventory",
|
"scan Worker retention inventory",
|
||||||
) {
|
) {
|
||||||
@@ -303,12 +300,7 @@ impl FsWorkerRetentionProvider {
|
|||||||
));
|
));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match self.inventory(
|
match self.inventory(workspace_id, runtime_id, worker_id) {
|
||||||
workspace_id,
|
|
||||||
runtime_id,
|
|
||||||
worker_id,
|
|
||||||
snapshot.run_generation(),
|
|
||||||
) {
|
|
||||||
Ok(item) => workers.push(item),
|
Ok(item) => workers.push(item),
|
||||||
Err(_) => diagnostics.push(runtime_aggregate_diagnostic(
|
Err(_) => diagnostics.push(runtime_aggregate_diagnostic(
|
||||||
&bounded_id,
|
&bounded_id,
|
||||||
@@ -380,26 +372,18 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
runtime_id: &str,
|
runtime_id: &str,
|
||||||
worker_id: WorkerId,
|
worker_id: WorkerId,
|
||||||
run_generation: u64,
|
|
||||||
) -> Result<WorkerRetentionInventory, RuntimeError> {
|
) -> Result<WorkerRetentionInventory, RuntimeError> {
|
||||||
let worker_dir = self.worker_dir(worker_id);
|
let worker_dir = self.worker_dir(worker_id);
|
||||||
if !worker_dir.is_dir() {
|
if !worker_dir.is_dir() {
|
||||||
return Err(RuntimeError::WorkerNotFound { worker_id });
|
return Err(RuntimeError::WorkerNotFound { worker_id });
|
||||||
}
|
}
|
||||||
let worker: WorkerGenerationSnapshot = read_json(
|
let worker: WorkerAggregateSnapshot = read_json(
|
||||||
&worker_dir.join("worker.json"),
|
&worker_dir.join("worker.json"),
|
||||||
"inventory Worker retention",
|
"inventory Worker retention",
|
||||||
)?;
|
)?;
|
||||||
if worker.workspace_id.as_deref() != Some(workspace_id) {
|
if worker.workspace_id.as_deref() != Some(workspace_id) {
|
||||||
return Err(RuntimeError::WorkerNotFound { worker_id });
|
return Err(RuntimeError::WorkerNotFound { worker_id });
|
||||||
}
|
}
|
||||||
let current_run_generation = worker.run_generation();
|
|
||||||
if current_run_generation != run_generation {
|
|
||||||
return Err(RuntimeError::InvalidRequest(format!(
|
|
||||||
"Worker retention inventory expected generation {run_generation}, current generation is {}",
|
|
||||||
current_run_generation
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let session_dir = worker_dir.join("session");
|
let session_dir = worker_dir.join("session");
|
||||||
let (session_id, segment_ids, session_bytes) = if session_dir.is_dir() {
|
let (session_id, segment_ids, session_bytes) = if session_dir.is_dir() {
|
||||||
let manifest: CanonicalSessionManifest = read_json(
|
let manifest: CanonicalSessionManifest = read_json(
|
||||||
@@ -437,7 +421,6 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
|||||||
workspace_id: workspace_id.to_string(),
|
workspace_id: workspace_id.to_string(),
|
||||||
runtime_id: runtime_id.to_string(),
|
runtime_id: runtime_id.to_string(),
|
||||||
worker_id,
|
worker_id,
|
||||||
run_generation,
|
|
||||||
session_id,
|
session_id,
|
||||||
segment_ids,
|
segment_ids,
|
||||||
session_bytes,
|
session_bytes,
|
||||||
@@ -497,21 +480,13 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
|||||||
worker_id: request.worker_id,
|
worker_id: request.worker_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let snapshot: WorkerGenerationSnapshot =
|
let snapshot: WorkerAggregateSnapshot =
|
||||||
read_json(&worker_dir.join("worker.json"), "execute Worker retention")?;
|
read_json(&worker_dir.join("worker.json"), "execute Worker retention")?;
|
||||||
if snapshot.workspace_id.as_deref() != Some(request.workspace_id.as_str()) {
|
if snapshot.workspace_id.as_deref() != Some(request.workspace_id.as_str()) {
|
||||||
return Err(RuntimeError::WorkerNotFound {
|
return Err(RuntimeError::WorkerNotFound {
|
||||||
worker_id: request.worker_id,
|
worker_id: request.worker_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let run_generation = snapshot.run_generation();
|
|
||||||
if run_generation != request.expected_run_generation {
|
|
||||||
return Err(RuntimeError::InvalidRequest(format!(
|
|
||||||
"Worker retention plan expected generation {}, current generation is {}",
|
|
||||||
request.expected_run_generation, run_generation
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let archive = match request.session_disposition {
|
let archive = match request.session_disposition {
|
||||||
SessionDisposition::Archive => {
|
SessionDisposition::Archive => {
|
||||||
Some(commit_session_archive(self, request, &worker_dir)?)
|
Some(commit_session_archive(self, request, &worker_dir)?)
|
||||||
@@ -572,30 +547,9 @@ impl WorkerRetentionProvider for FsWorkerRetentionProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct WorkerGenerationSnapshot {
|
struct WorkerAggregateSnapshot {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
workspace_id: Option<String>,
|
workspace_id: Option<String>,
|
||||||
execution: WorkerGenerationExecution,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct WorkerGenerationExecution {
|
|
||||||
binding: Option<WorkerGenerationBinding>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct WorkerGenerationBinding {
|
|
||||||
run_generation: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkerGenerationSnapshot {
|
|
||||||
fn run_generation(&self) -> u64 {
|
|
||||||
self.execution
|
|
||||||
.binding
|
|
||||||
.as_ref()
|
|
||||||
.map(|binding| binding.run_generation)
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -1286,13 +1240,12 @@ mod tests {
|
|||||||
fs::write(path, serde_json::to_vec_pretty(value).unwrap()).unwrap();
|
fs::write(path, serde_json::to_vec_pretty(value).unwrap()).unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn source(root: &Path, worker_id: WorkerId, generation: u64) {
|
fn source(root: &Path, worker_id: WorkerId) {
|
||||||
let worker = root.join("workers").join(worker_id.to_string());
|
let worker = root.join("workers").join(worker_id.to_string());
|
||||||
write_json(
|
write_json(
|
||||||
&worker.join("worker.json"),
|
&worker.join("worker.json"),
|
||||||
&serde_json::json!({
|
&serde_json::json!({
|
||||||
"workspace_id": "workspace-a",
|
"workspace_id": "workspace-a"
|
||||||
"execution": {"binding": {"run_generation": generation}}
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
write_json(
|
write_json(
|
||||||
@@ -1301,22 +1254,17 @@ mod tests {
|
|||||||
);
|
);
|
||||||
fs::create_dir_all(worker.join("session/segments")).unwrap();
|
fs::create_dir_all(worker.join("session/segments")).unwrap();
|
||||||
fs::write(worker.join("session/segments/segment-a.jsonl"), b"one\n").unwrap();
|
fs::write(worker.join("session/segments/segment-a.jsonl"), b"one\n").unwrap();
|
||||||
fs::create_dir_all(worker.join(format!("runs/{generation}"))).unwrap();
|
fs::create_dir_all(worker.join("runs/attempt-a")).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
worker.join(format!("runs/{generation}/worker.out.log")),
|
worker.join("runs/attempt-a/worker.out.log"),
|
||||||
b"diagnostic\n",
|
b"diagnostic\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
fs::write(
|
fs::write(worker.join("runs/attempt-a/worker.sock"), b"not retained").unwrap();
|
||||||
worker.join(format!("runs/{generation}/worker.sock")),
|
|
||||||
b"not retained",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request(
|
fn request(
|
||||||
worker_id: WorkerId,
|
worker_id: WorkerId,
|
||||||
generation: u64,
|
|
||||||
disposition: SessionDisposition,
|
disposition: SessionDisposition,
|
||||||
) -> WorkerRetentionExecutionRequest {
|
) -> WorkerRetentionExecutionRequest {
|
||||||
WorkerRetentionExecutionRequest {
|
WorkerRetentionExecutionRequest {
|
||||||
@@ -1328,7 +1276,6 @@ mod tests {
|
|||||||
workspace_id: "workspace-a".to_string(),
|
workspace_id: "workspace-a".to_string(),
|
||||||
source_runtime_id: "runtime-a".to_string(),
|
source_runtime_id: "runtime-a".to_string(),
|
||||||
worker_id,
|
worker_id,
|
||||||
expected_run_generation: generation,
|
|
||||||
source_created_at: "2026-01-01T00:00:00Z".to_string(),
|
source_created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
removed_at: "2026-01-02T00:00:00Z".to_string(),
|
removed_at: "2026-01-02T00:00:00Z".to_string(),
|
||||||
effective_profile: Some("builtin:coder".to_string()),
|
effective_profile: Some("builtin:coder".to_string()),
|
||||||
@@ -1344,9 +1291,9 @@ mod tests {
|
|||||||
fn archive_is_verified_before_source_removal_and_retry_converges() {
|
fn archive_is_verified_before_source_removal_and_retry_converges() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let worker_id = WorkerId::from_legacy_u64(7);
|
let worker_id = WorkerId::from_legacy_u64(7);
|
||||||
source(temp.path(), worker_id, 4);
|
source(temp.path(), worker_id);
|
||||||
let provider = FsWorkerRetentionProvider::new(temp.path());
|
let provider = FsWorkerRetentionProvider::new(temp.path());
|
||||||
let request = request(worker_id, 4, SessionDisposition::Archive);
|
let request = request(worker_id, SessionDisposition::Archive);
|
||||||
|
|
||||||
let first = provider.execute(&request).unwrap();
|
let first = provider.execute(&request).unwrap();
|
||||||
assert!(first.source_removed);
|
assert!(first.source_removed);
|
||||||
@@ -1375,7 +1322,7 @@ mod tests {
|
|||||||
fn archive_failure_keeps_live_source_for_retry() {
|
fn archive_failure_keeps_live_source_for_retry() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let worker_id = WorkerId::from_legacy_u64(8);
|
let worker_id = WorkerId::from_legacy_u64(8);
|
||||||
source(temp.path(), worker_id, 2);
|
source(temp.path(), worker_id);
|
||||||
let collision = temp.path().join("archives/workers/archive-a");
|
let collision = temp.path().join("archives/workers/archive-a");
|
||||||
fs::create_dir_all(&collision).unwrap();
|
fs::create_dir_all(&collision).unwrap();
|
||||||
fs::write(collision.join("manifest.json"), b"not-json").unwrap();
|
fs::write(collision.join("manifest.json"), b"not-json").unwrap();
|
||||||
@@ -1383,7 +1330,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
provider
|
provider
|
||||||
.execute(&request(worker_id, 2, SessionDisposition::Archive))
|
.execute(&request(worker_id, SessionDisposition::Archive))
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1403,13 +1350,13 @@ mod tests {
|
|||||||
fn target_inventory_and_execute_reject_cross_workspace_aggregate() {
|
fn target_inventory_and_execute_reject_cross_workspace_aggregate() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let worker_id = WorkerId::from_legacy_u64(16);
|
let worker_id = WorkerId::from_legacy_u64(16);
|
||||||
source(temp.path(), worker_id, 3);
|
source(temp.path(), worker_id);
|
||||||
let provider = FsWorkerRetentionProvider::new(temp.path());
|
let provider = FsWorkerRetentionProvider::new(temp.path());
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
provider.inventory("other-workspace", "runtime-a", worker_id, 3),
|
provider.inventory("other-workspace", "runtime-a", worker_id),
|
||||||
Err(RuntimeError::WorkerNotFound { .. })
|
Err(RuntimeError::WorkerNotFound { .. })
|
||||||
));
|
));
|
||||||
let mut request = request(worker_id, 3, SessionDisposition::Purge);
|
let mut request = request(worker_id, SessionDisposition::Purge);
|
||||||
request.workspace_id = "other-workspace".to_string();
|
request.workspace_id = "other-workspace".to_string();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
provider.execute(&request),
|
provider.execute(&request),
|
||||||
@@ -1434,20 +1381,12 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn purge_removes_aggregate_and_rejects_stale_generation() {
|
fn purge_removes_worker_aggregate() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let provider = FsWorkerRetentionProvider::new(temp.path());
|
let provider = FsWorkerRetentionProvider::new(temp.path());
|
||||||
let worker_id = WorkerId::from_legacy_u64(9);
|
let worker_id = WorkerId::from_legacy_u64(9);
|
||||||
source(temp.path(), worker_id, 5);
|
source(temp.path(), worker_id);
|
||||||
let stale = request(worker_id, 4, SessionDisposition::Purge);
|
let mut current = request(worker_id, SessionDisposition::Purge);
|
||||||
assert!(provider.execute(&stale).is_err());
|
|
||||||
assert!(
|
|
||||||
temp.path()
|
|
||||||
.join(format!("workers/{worker_id}/session"))
|
|
||||||
.is_dir()
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut current = request(worker_id, 5, SessionDisposition::Purge);
|
|
||||||
current.operation_id = "operation-current".to_string();
|
current.operation_id = "operation-current".to_string();
|
||||||
current.input_fingerprint = "fingerprint-current".to_string();
|
current.input_fingerprint = "fingerprint-current".to_string();
|
||||||
let result = provider.execute(¤t).unwrap();
|
let result = provider.execute(¤t).unwrap();
|
||||||
@@ -1464,9 +1403,9 @@ mod tests {
|
|||||||
fn pending_receipt_recovers_delete_to_receipt_crash_window() {
|
fn pending_receipt_recovers_delete_to_receipt_crash_window() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let worker_id = WorkerId::from_legacy_u64(11);
|
let worker_id = WorkerId::from_legacy_u64(11);
|
||||||
source(temp.path(), worker_id, 1);
|
source(temp.path(), worker_id);
|
||||||
let provider = FsWorkerRetentionProvider::new(temp.path());
|
let provider = FsWorkerRetentionProvider::new(temp.path());
|
||||||
let request = request(worker_id, 1, SessionDisposition::Archive);
|
let request = request(worker_id, SessionDisposition::Archive);
|
||||||
let completed = provider.execute(&request).unwrap();
|
let completed = provider.execute(&request).unwrap();
|
||||||
let receipt_path = temp.path().join("retention/operations/operation-a.json");
|
let receipt_path = temp.path().join("retention/operations/operation-a.json");
|
||||||
let mut receipt: RetentionOperationReceipt =
|
let mut receipt: RetentionOperationReceipt =
|
||||||
@@ -1482,9 +1421,9 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn provider_snapshot_scans_aggregate_storage_independent_of_runtime_catalog() {
|
fn provider_snapshot_scans_aggregate_storage_independent_of_runtime_catalog() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
source(temp.path(), WorkerId::from_legacy_u64(13), 2);
|
source(temp.path(), WorkerId::from_legacy_u64(13));
|
||||||
let other_worker = WorkerId::from_legacy_u64(14);
|
let other_worker = WorkerId::from_legacy_u64(14);
|
||||||
source(temp.path(), other_worker, 1);
|
source(temp.path(), other_worker);
|
||||||
write_json(
|
write_json(
|
||||||
&temp
|
&temp
|
||||||
.path()
|
.path()
|
||||||
@@ -1492,8 +1431,7 @@ mod tests {
|
|||||||
.join(other_worker.to_string())
|
.join(other_worker.to_string())
|
||||||
.join("worker.json"),
|
.join("worker.json"),
|
||||||
&serde_json::json!({
|
&serde_json::json!({
|
||||||
"workspace_id": "other-workspace",
|
"workspace_id": "other-workspace"
|
||||||
"execution": {"binding": {"run_generation": 1}}
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fs::create_dir_all(temp.path().join("workers/not-a-worker")).unwrap();
|
fs::create_dir_all(temp.path().join("workers/not-a-worker")).unwrap();
|
||||||
@@ -1532,9 +1470,9 @@ mod tests {
|
|||||||
fn diagnostics_retry_rejects_corrupt_existing_archive_before_source_delete() {
|
fn diagnostics_retry_rejects_corrupt_existing_archive_before_source_delete() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let worker_id = WorkerId::from_legacy_u64(12);
|
let worker_id = WorkerId::from_legacy_u64(12);
|
||||||
source(temp.path(), worker_id, 1);
|
source(temp.path(), worker_id);
|
||||||
let provider = FsWorkerRetentionProvider::new(temp.path());
|
let provider = FsWorkerRetentionProvider::new(temp.path());
|
||||||
let mut request = request(worker_id, 1, SessionDisposition::Archive);
|
let mut request = request(worker_id, SessionDisposition::Archive);
|
||||||
request.diagnostics_disposition = DiagnosticsDisposition::Retain;
|
request.diagnostics_disposition = DiagnosticsDisposition::Retain;
|
||||||
provider.execute(&request).unwrap();
|
provider.execute(&request).unwrap();
|
||||||
|
|
||||||
@@ -1543,10 +1481,10 @@ mod tests {
|
|||||||
serde_json::from_slice(&fs::read(&receipt_path).unwrap()).unwrap();
|
serde_json::from_slice(&fs::read(&receipt_path).unwrap()).unwrap();
|
||||||
receipt.result.source_removed = false;
|
receipt.result.source_removed = false;
|
||||||
fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt).unwrap()).unwrap();
|
fs::write(&receipt_path, serde_json::to_vec_pretty(&receipt).unwrap()).unwrap();
|
||||||
source(temp.path(), worker_id, 1);
|
source(temp.path(), worker_id);
|
||||||
fs::write(
|
fs::write(
|
||||||
temp.path()
|
temp.path()
|
||||||
.join("archives/diagnostics/operation-a/runs/1/worker.out.log"),
|
.join("archives/diagnostics/operation-a/runs/attempt-a/worker.out.log"),
|
||||||
b"corrupt\n",
|
b"corrupt\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1564,9 +1502,9 @@ mod tests {
|
|||||||
fn concurrent_retry_produces_one_archive() {
|
fn concurrent_retry_produces_one_archive() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let worker_id = WorkerId::from_legacy_u64(10);
|
let worker_id = WorkerId::from_legacy_u64(10);
|
||||||
source(temp.path(), worker_id, 1);
|
source(temp.path(), worker_id);
|
||||||
let provider = Arc::new(FsWorkerRetentionProvider::new(temp.path()));
|
let provider = Arc::new(FsWorkerRetentionProvider::new(temp.path()));
|
||||||
let request = Arc::new(request(worker_id, 1, SessionDisposition::Archive));
|
let request = Arc::new(request(worker_id, SessionDisposition::Archive));
|
||||||
let barrier = Arc::new(Barrier::new(3));
|
let barrier = Arc::new(Barrier::new(3));
|
||||||
let handles = (0..2)
|
let handles = (0..2)
|
||||||
.map(|_| {
|
.map(|_| {
|
||||||
|
|||||||
+1728
-527
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,368 @@
|
|||||||
|
//! Side-effect-free SSH host key discovery for Repository trust enrollment.
|
||||||
|
//!
|
||||||
|
//! Probing only observes public host keys. It does not persist trust, use clone
|
||||||
|
//! credentials, or authenticate to the target host.
|
||||||
|
|
||||||
|
use base64::Engine as _;
|
||||||
|
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Stdio;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
pub const SSH_HOST_KEY_PROBE_PATH: &str = "/v1/repositories/ssh/probe";
|
||||||
|
pub const SSH_HOST_KEY_PROBE_OPERATION: &str = "workdirs:operate";
|
||||||
|
pub(crate) const SSH_KEYSCAN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
const SSH_KEYSCAN_CONNECT_TIMEOUT_SECONDS: &str = "5";
|
||||||
|
const MAX_SSH_KEYSCAN_OUTPUT_BYTES: usize = 64 * 1024;
|
||||||
|
const MAX_PROBE_CANDIDATES: usize = 32;
|
||||||
|
const MAX_DIAGNOSTIC_BYTES: usize = 256;
|
||||||
|
|
||||||
|
/// `POST /v1/repositories/ssh/probe` request.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SshHostKeyProbeRequest {
|
||||||
|
pub hostname: String,
|
||||||
|
pub port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One public host key observed by an SSH host key probe.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SshHostKeyCandidate {
|
||||||
|
/// Canonical OpenSSH public key text (`algorithm base64-key`), without a host prefix.
|
||||||
|
pub public_key: String,
|
||||||
|
/// OpenSSH public key algorithm name.
|
||||||
|
pub algorithm: String,
|
||||||
|
/// OpenSSH SHA-256 fingerprint (`SHA256:base64-digest`).
|
||||||
|
pub fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `POST /v1/repositories/ssh/probe` response.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SshHostKeyProbeResponse {
|
||||||
|
pub candidates: Vec<SshHostKeyCandidate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||||
|
pub enum SshHostKeyProbeError {
|
||||||
|
#[error("SSH host key probe hostname is invalid")]
|
||||||
|
InvalidHostname,
|
||||||
|
#[error("SSH host key probe port must be greater than zero")]
|
||||||
|
InvalidPort,
|
||||||
|
#[error("SSH host key probe executable is unavailable")]
|
||||||
|
Unavailable,
|
||||||
|
#[error("SSH host key probe timed out")]
|
||||||
|
Timeout,
|
||||||
|
#[error("SSH host key probe failed: {diagnostic}")]
|
||||||
|
Failed { diagnostic: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observe the target's public Ed25519 host keys without persisting trust or using credentials.
|
||||||
|
pub async fn probe_ssh_host_keys(
|
||||||
|
request: &SshHostKeyProbeRequest,
|
||||||
|
) -> Result<SshHostKeyProbeResponse, SshHostKeyProbeError> {
|
||||||
|
probe_ssh_host_keys_with_program(request, Path::new("ssh-keyscan"), SSH_KEYSCAN_TIMEOUT).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn probe_ssh_host_keys_with_program(
|
||||||
|
request: &SshHostKeyProbeRequest,
|
||||||
|
program: &Path,
|
||||||
|
timeout: Duration,
|
||||||
|
) -> Result<SshHostKeyProbeResponse, SshHostKeyProbeError> {
|
||||||
|
validate_request(request)?;
|
||||||
|
|
||||||
|
let mut command = Command::new(program);
|
||||||
|
command
|
||||||
|
.args(["-T", SSH_KEYSCAN_CONNECT_TIMEOUT_SECONDS])
|
||||||
|
.arg("-p")
|
||||||
|
.arg(request.port.to_string())
|
||||||
|
.args(["-t", "ed25519"])
|
||||||
|
.arg(&request.hostname)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
// ssh-keyscan diagnostics are intentionally not returned or retained: they may contain
|
||||||
|
// environment-specific details and are not needed for the public error contract.
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
|
||||||
|
let output = tokio::time::timeout(timeout, command.output())
|
||||||
|
.await
|
||||||
|
.map_err(|_| SshHostKeyProbeError::Timeout)?
|
||||||
|
.map_err(|_| SshHostKeyProbeError::Unavailable)?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
return Err(SshHostKeyProbeError::Failed {
|
||||||
|
diagnostic: bounded_diagnostic(format!(
|
||||||
|
"ssh-keyscan exited unsuccessfully ({})",
|
||||||
|
output.status
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if output.stdout.len() > MAX_SSH_KEYSCAN_OUTPUT_BYTES {
|
||||||
|
return Err(SshHostKeyProbeError::Failed {
|
||||||
|
diagnostic: "ssh-keyscan output exceeded the probe limit".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidates = parse_ssh_keyscan_output(&output.stdout);
|
||||||
|
if candidates.is_empty() {
|
||||||
|
return Err(SshHostKeyProbeError::Failed {
|
||||||
|
diagnostic: "ssh-keyscan returned no valid ssh-ed25519 host keys".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(SshHostKeyProbeResponse { candidates })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_request(request: &SshHostKeyProbeRequest) -> Result<(), SshHostKeyProbeError> {
|
||||||
|
if request.port == 0 {
|
||||||
|
return Err(SshHostKeyProbeError::InvalidPort);
|
||||||
|
}
|
||||||
|
validate_hostname(&request.hostname)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_hostname(hostname: &str) -> Result<(), SshHostKeyProbeError> {
|
||||||
|
if hostname.is_empty()
|
||||||
|
|| hostname.len() > 253
|
||||||
|
|| !hostname.is_ascii()
|
||||||
|
|| hostname.bytes().any(|byte| byte.is_ascii_whitespace())
|
||||||
|
|| hostname.starts_with('-')
|
||||||
|
{
|
||||||
|
return Err(SshHostKeyProbeError::InvalidHostname);
|
||||||
|
}
|
||||||
|
if hostname.parse::<IpAddr>().is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let hostname = hostname.strip_suffix('.').unwrap_or(hostname);
|
||||||
|
if hostname.is_empty()
|
||||||
|
|| hostname.split('.').any(|label| {
|
||||||
|
label.is_empty()
|
||||||
|
|| label.len() > 63
|
||||||
|
|| label.starts_with('-')
|
||||||
|
|| label.ends_with('-')
|
||||||
|
|| !label
|
||||||
|
.bytes()
|
||||||
|
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
|
||||||
|
})
|
||||||
|
{
|
||||||
|
return Err(SshHostKeyProbeError::InvalidHostname);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_ssh_keyscan_output(output: &[u8]) -> Vec<SshHostKeyCandidate> {
|
||||||
|
let mut seen = BTreeSet::new();
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
for line in output.split(|byte| *byte == b'\n') {
|
||||||
|
let Ok(line) = std::str::from_utf8(line) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let mut fields = line.split_ascii_whitespace();
|
||||||
|
let (Some(_host), Some(algorithm), Some(encoded_key)) =
|
||||||
|
(fields.next(), fields.next(), fields.next())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if line.trim_start().starts_with('#') || algorithm != "ssh-ed25519" {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Ok(key_blob) = STANDARD.decode(encoded_key) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !is_ed25519_public_key_blob(&key_blob) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let canonical_key = STANDARD.encode(&key_blob);
|
||||||
|
if !seen.insert(canonical_key.clone()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let public_key = format!("{algorithm} {canonical_key}");
|
||||||
|
candidates.push(SshHostKeyCandidate {
|
||||||
|
algorithm: algorithm.to_string(),
|
||||||
|
fingerprint: format!(
|
||||||
|
"SHA256:{}",
|
||||||
|
STANDARD_NO_PAD.encode(Sha256::digest(&key_blob))
|
||||||
|
),
|
||||||
|
public_key,
|
||||||
|
});
|
||||||
|
if candidates.len() == MAX_PROBE_CANDIDATES {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_ed25519_public_key_blob(blob: &[u8]) -> bool {
|
||||||
|
let Some((algorithm, rest)) = take_ssh_string(blob) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some((public_key, rest)) = take_ssh_string(rest) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
algorithm == b"ssh-ed25519" && public_key.len() == 32 && rest.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn take_ssh_string(input: &[u8]) -> Option<(&[u8], &[u8])> {
|
||||||
|
let length = u32::from_be_bytes(input.get(..4)?.try_into().ok()?) as usize;
|
||||||
|
let value = input.get(4..4usize.checked_add(length)?)?;
|
||||||
|
let rest = input.get(4usize.checked_add(length)?..)?;
|
||||||
|
Some((value, rest))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bounded_diagnostic(mut diagnostic: String) -> String {
|
||||||
|
if diagnostic.len() <= MAX_DIAGNOSTIC_BYTES {
|
||||||
|
return diagnostic;
|
||||||
|
}
|
||||||
|
let mut end = MAX_DIAGNOSTIC_BYTES;
|
||||||
|
while !diagnostic.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
diagnostic.truncate(end);
|
||||||
|
diagnostic
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn encoded_ed25519_key(seed: u8) -> String {
|
||||||
|
let mut blob = Vec::new();
|
||||||
|
blob.extend_from_slice(&("ssh-ed25519".len() as u32).to_be_bytes());
|
||||||
|
blob.extend_from_slice(b"ssh-ed25519");
|
||||||
|
blob.extend_from_slice(&32_u32.to_be_bytes());
|
||||||
|
blob.extend_from_slice(&[seed; 32]);
|
||||||
|
STANDARD.encode(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostname_validation_rejects_option_injection_and_ambiguous_text() {
|
||||||
|
for hostname in [
|
||||||
|
"",
|
||||||
|
"-example.test",
|
||||||
|
"--help",
|
||||||
|
"example.test other.test",
|
||||||
|
"example.test\nother.test",
|
||||||
|
"example_test",
|
||||||
|
".example.test",
|
||||||
|
"example..test",
|
||||||
|
"example.test:22",
|
||||||
|
"[::1]",
|
||||||
|
"éxample.test",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
validate_hostname(hostname),
|
||||||
|
Err(SshHostKeyProbeError::InvalidHostname),
|
||||||
|
"{hostname:?} must be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for hostname in [
|
||||||
|
"localhost",
|
||||||
|
"example.test",
|
||||||
|
"example.test.",
|
||||||
|
"127.0.0.1",
|
||||||
|
"::1",
|
||||||
|
] {
|
||||||
|
validate_hostname(hostname).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn request_validation_rejects_zero_port() {
|
||||||
|
assert_eq!(
|
||||||
|
validate_request(&SshHostKeyProbeRequest {
|
||||||
|
hostname: "example.test".to_string(),
|
||||||
|
port: 0,
|
||||||
|
}),
|
||||||
|
Err(SshHostKeyProbeError::InvalidPort)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_accepts_only_valid_ed25519_keys_and_deduplicates() {
|
||||||
|
let key = encoded_ed25519_key(7);
|
||||||
|
let other_key = encoded_ed25519_key(8);
|
||||||
|
let output = format!(
|
||||||
|
"# comment\nexample.test ssh-rsa AAAA\nexample.test ssh-ed25519 invalid!\nexample.test ssh-ed25519 {key}\n[example.test]:2222 ssh-ed25519 {key}\nexample.test ssh-ed25519 {other_key}\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
let candidates = parse_ssh_keyscan_output(output.as_bytes());
|
||||||
|
|
||||||
|
assert_eq!(candidates.len(), 2);
|
||||||
|
assert_eq!(candidates[0].algorithm, "ssh-ed25519");
|
||||||
|
assert_eq!(candidates[0].public_key, format!("ssh-ed25519 {key}"));
|
||||||
|
let decoded = STANDARD.decode(key).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
candidates[0].fingerprint,
|
||||||
|
format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(decoded)))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parser_rejects_base64_that_is_not_an_ed25519_wire_key() {
|
||||||
|
let output = format!("example.test ssh-ed25519 {}\n", STANDARD.encode([1_u8; 32]));
|
||||||
|
assert!(parse_ssh_keyscan_output(output.as_bytes()).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unsuccessful_command_does_not_return_stderr() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let program = temp.path().join("ssh-keyscan");
|
||||||
|
std::fs::write(
|
||||||
|
&program,
|
||||||
|
"#!/bin/sh\nprintf 'secret from stderr' >&2\nexit 7\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
let error = probe_ssh_host_keys_with_program(
|
||||||
|
&SshHostKeyProbeRequest {
|
||||||
|
hostname: "example.test".to_string(),
|
||||||
|
port: 22,
|
||||||
|
},
|
||||||
|
&program,
|
||||||
|
Duration::from_secs(1),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
let diagnostic = error.to_string();
|
||||||
|
assert!(matches!(error, SshHostKeyProbeError::Failed { .. }));
|
||||||
|
assert!(!diagnostic.contains("secret"));
|
||||||
|
assert!(diagnostic.len() <= MAX_DIAGNOSTIC_BYTES + "SSH host key probe failed: ".len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn command_execution_times_out_without_returning_process_diagnostics() {
|
||||||
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let program = temp.path().join("ssh-keyscan");
|
||||||
|
std::fs::write(
|
||||||
|
&program,
|
||||||
|
"#!/bin/sh\nprintf 'secret from stderr' >&2\nsleep 2\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
let request = SshHostKeyProbeRequest {
|
||||||
|
hostname: "example.test".to_string(),
|
||||||
|
port: 22,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = probe_ssh_host_keys_with_program(&request, &program, Duration::from_millis(20))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert_eq!(error, SshHostKeyProbeError::Timeout);
|
||||||
|
assert!(!error.to_string().contains("secret"));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
|
||||||
use worker::{
|
use worker::{
|
||||||
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution,
|
WorkspaceClient, WorkspaceClientError, WorkspacePromptCatalogResolution,
|
||||||
WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
WorkspacePromptProjection, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial,
|
RuntimeAuthError, RuntimeIdentityMaterial, RuntimeWorkerMutationSourceSigner,
|
||||||
RuntimeRequestSourceSigner, RuntimeWorkerMutationSourceSigner, WORKER_REMOVE_PERMISSION,
|
WORKER_REMOVE_PERMISSION, WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION,
|
||||||
WORKSPACE_REQUEST_PERMISSION, WORKSPACE_WORKER_DISCOVERY_PERMISSION, WorkerMutationActorKind,
|
WorkerMutationActorKind, WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
|
||||||
WorkerMutationOperation, WorkerMutationSourceClaims, new_token_id,
|
|
||||||
};
|
};
|
||||||
use crate::runtime::RuntimeWorkspaceScope;
|
use crate::runtime::RuntimeWorkspaceScope;
|
||||||
use crate::worker_backend::WorkspacePromptProjectionCache;
|
use crate::worker_backend::WorkspacePromptProjectionCache;
|
||||||
@@ -133,9 +133,7 @@ pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
enum RuntimeWorkerMutationTransport {
|
enum RuntimeWorkerMutationTransport {
|
||||||
Remote {
|
Remote {
|
||||||
base_url: String,
|
request_client: RuntimeWorkspaceRequestClient,
|
||||||
request_source_signer: RuntimeRequestSourceSigner,
|
|
||||||
request_source_audience: String,
|
|
||||||
},
|
},
|
||||||
Embedded {
|
Embedded {
|
||||||
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
|
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
|
||||||
@@ -155,17 +153,13 @@ impl RuntimeWorkerMutationForwarder {
|
|||||||
identity: &RuntimeIdentityMaterial,
|
identity: &RuntimeIdentityMaterial,
|
||||||
scope: RuntimeWorkspaceScope,
|
scope: RuntimeWorkspaceScope,
|
||||||
source_worker_id: impl Into<String>,
|
source_worker_id: impl Into<String>,
|
||||||
base_url: impl Into<String>,
|
request_client: RuntimeWorkspaceRequestClient,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
authority: RuntimeWorkerMutationSourceAuthority::remote(identity),
|
authority: RuntimeWorkerMutationSourceAuthority::remote(identity),
|
||||||
scope: scope.clone(),
|
scope: scope.clone(),
|
||||||
source_worker_id: source_worker_id.into(),
|
source_worker_id: source_worker_id.into(),
|
||||||
transport: RuntimeWorkerMutationTransport::Remote {
|
transport: RuntimeWorkerMutationTransport::Remote { request_client },
|
||||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
|
||||||
request_source_signer: RuntimeRequestSourceSigner::from_identity(identity),
|
|
||||||
request_source_audience: scope.server_id,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,18 +195,11 @@ impl RuntimeWorkerMutationForwarder {
|
|||||||
)?;
|
)?;
|
||||||
match (&self.transport, proof) {
|
match (&self.transport, proof) {
|
||||||
(
|
(
|
||||||
RuntimeWorkerMutationTransport::Remote {
|
RuntimeWorkerMutationTransport::Remote { request_client },
|
||||||
base_url,
|
|
||||||
request_source_signer,
|
|
||||||
request_source_audience,
|
|
||||||
},
|
|
||||||
RuntimeOwnedWorkerMutationProof::Remote(token),
|
RuntimeOwnedWorkerMutationProof::Remote(token),
|
||||||
) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest {
|
) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest {
|
||||||
base_url: base_url.clone(),
|
request_client: request_client.clone(),
|
||||||
workspace_id: self.scope.workspace_id.clone(),
|
|
||||||
source_worker_id: self.source_worker_id.clone(),
|
source_worker_id: self.source_worker_id.clone(),
|
||||||
request_source_signer: request_source_signer.clone(),
|
|
||||||
request_source_audience: request_source_audience.clone(),
|
|
||||||
token,
|
token,
|
||||||
target_runtime_id: target_runtime_id.to_string(),
|
target_runtime_id: target_runtime_id.to_string(),
|
||||||
target_worker_id: target_worker_id.to_string(),
|
target_worker_id: target_worker_id.to_string(),
|
||||||
@@ -233,11 +220,8 @@ impl RuntimeWorkerMutationForwarder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct RemoteWorkerRemoveHttpRequest {
|
struct RemoteWorkerRemoveHttpRequest {
|
||||||
base_url: String,
|
request_client: RuntimeWorkspaceRequestClient,
|
||||||
workspace_id: String,
|
|
||||||
source_worker_id: String,
|
source_worker_id: String,
|
||||||
request_source_signer: RuntimeRequestSourceSigner,
|
|
||||||
request_source_audience: String,
|
|
||||||
token: String,
|
token: String,
|
||||||
target_runtime_id: String,
|
target_runtime_id: String,
|
||||||
target_worker_id: String,
|
target_worker_id: String,
|
||||||
@@ -270,54 +254,54 @@ fn execute_remote_worker_remove_http(
|
|||||||
fn execute_remote_worker_remove_http_blocking(
|
fn execute_remote_worker_remove_http_blocking(
|
||||||
request: RemoteWorkerRemoveHttpRequest,
|
request: RemoteWorkerRemoveHttpRequest,
|
||||||
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
|
||||||
let path = format!("/api/w/{}/workers/remove", request.workspace_id);
|
let path = format!(
|
||||||
let url = format!("{}{}", request.base_url, path);
|
"/api/w/{}/workers/remove",
|
||||||
let body = serde_json::to_string(&serde_json::json!({
|
request.request_client.workspace_id()
|
||||||
|
);
|
||||||
|
let body = serde_json::to_vec(&serde_json::json!({
|
||||||
"target_runtime_id": request.target_runtime_id,
|
"target_runtime_id": request.target_runtime_id,
|
||||||
"target_worker_id": request.target_worker_id,
|
"target_worker_id": request.target_worker_id,
|
||||||
"reason": request.reason,
|
"reason": request.reason,
|
||||||
}))
|
}))
|
||||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||||
let request_source_proof = request.request_source_signer.issue(
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
&request.request_source_audience,
|
headers.insert(
|
||||||
&request.workspace_id,
|
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
|
||||||
Some(&request.source_worker_id),
|
reqwest::header::HeaderValue::from_str(&request.token)
|
||||||
WORKSPACE_REQUEST_PERMISSION,
|
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?,
|
||||||
"POST",
|
);
|
||||||
&path,
|
headers.insert(
|
||||||
body.as_bytes(),
|
reqwest::header::CONTENT_TYPE,
|
||||||
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
reqwest::header::HeaderValue::from_static("application/json"),
|
||||||
30,
|
);
|
||||||
)?;
|
let response = request
|
||||||
let client = reqwest::blocking::Client::new();
|
.request_client
|
||||||
let response = client
|
.execute_blocking(RuntimeWorkspaceRequest {
|
||||||
.post(url)
|
method: reqwest::Method::POST,
|
||||||
.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, request_source_proof)
|
path_and_query: path,
|
||||||
.header(
|
body,
|
||||||
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
|
headers,
|
||||||
request.token,
|
permission: WORKSPACE_REQUEST_PERMISSION.to_string(),
|
||||||
)
|
worker_id: Some(request.source_worker_id),
|
||||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
timeout: Some(Duration::from_secs(5)),
|
||||||
.body(body)
|
max_response_bytes: 8 * 1024 * 1024,
|
||||||
.send()
|
})
|
||||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||||
let status = response.status().as_u16();
|
let body = String::from_utf8(response.body)
|
||||||
let body = response
|
|
||||||
.text()
|
|
||||||
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
|
||||||
Ok(WorkspaceResponse { status, body })
|
Ok(WorkspaceResponse {
|
||||||
|
status: response.status.as_u16(),
|
||||||
|
body,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct RuntimeOwnedWorkspaceClient {
|
pub struct RuntimeOwnedWorkspaceClient {
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
base_url: String,
|
request_client: RuntimeWorkspaceRequestClient,
|
||||||
runtime_id: String,
|
|
||||||
worker_id: String,
|
worker_id: String,
|
||||||
request_timeout: Option<Duration>,
|
request_timeout: Option<Duration>,
|
||||||
worker_remove: Option<RuntimeWorkerMutationForwarder>,
|
worker_remove: Option<RuntimeWorkerMutationForwarder>,
|
||||||
request_source_signer: Option<RuntimeRequestSourceSigner>,
|
|
||||||
request_source_audience: Option<String>,
|
|
||||||
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
|
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,15 +312,32 @@ impl RuntimeOwnedWorkspaceClient {
|
|||||||
runtime_id: impl Into<String>,
|
runtime_id: impl Into<String>,
|
||||||
worker_id: impl Into<String>,
|
worker_id: impl Into<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let workspace_id = workspace_id.into();
|
||||||
Self {
|
Self {
|
||||||
workspace_id: workspace_id.into(),
|
request_client: RuntimeWorkspaceRequestClient::new(
|
||||||
base_url: base_url.into().trim_end_matches('/').to_string(),
|
workspace_id.clone(),
|
||||||
runtime_id: runtime_id.into(),
|
base_url,
|
||||||
|
runtime_id,
|
||||||
|
),
|
||||||
|
workspace_id,
|
||||||
|
worker_id: worker_id.into(),
|
||||||
|
request_timeout: None,
|
||||||
|
worker_remove: None,
|
||||||
|
prompt_projection_cache: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn from_request_client(
|
||||||
|
request_client: RuntimeWorkspaceRequestClient,
|
||||||
|
worker_id: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
let workspace_id = request_client.workspace_id().to_string();
|
||||||
|
Self {
|
||||||
|
workspace_id,
|
||||||
|
request_client,
|
||||||
worker_id: worker_id.into(),
|
worker_id: worker_id.into(),
|
||||||
request_timeout: None,
|
request_timeout: None,
|
||||||
worker_remove: None,
|
worker_remove: None,
|
||||||
request_source_signer: None,
|
|
||||||
request_source_audience: None,
|
|
||||||
prompt_projection_cache: None,
|
prompt_projection_cache: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -351,8 +352,9 @@ impl RuntimeOwnedWorkspaceClient {
|
|||||||
identity: &RuntimeIdentityMaterial,
|
identity: &RuntimeIdentityMaterial,
|
||||||
audience: impl Into<String>,
|
audience: impl Into<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
self.request_source_signer = Some(RuntimeRequestSourceSigner::from_identity(identity));
|
self.request_client = self
|
||||||
self.request_source_audience = Some(audience.into());
|
.request_client
|
||||||
|
.with_runtime_request_source(identity, audience);
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,44 +377,43 @@ impl RuntimeOwnedWorkspaceClient {
|
|||||||
request: WorkspaceRequest,
|
request: WorkspaceRequest,
|
||||||
permission: &'static str,
|
permission: &'static str,
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
let base_url = self.base_url.clone();
|
let method = match request.method {
|
||||||
let workspace_id = self.workspace_id.clone();
|
WorkspaceRequestMethod::Get => reqwest::Method::GET,
|
||||||
let runtime_id = self.runtime_id.clone();
|
WorkspaceRequestMethod::Post => reqwest::Method::POST,
|
||||||
let worker_id = self.worker_id.clone();
|
WorkspaceRequestMethod::Put => reqwest::Method::PUT,
|
||||||
let request_source_signer = self.request_source_signer.clone();
|
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
|
||||||
let request_source_audience = self.request_source_audience.clone();
|
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
|
||||||
let request_timeout = self.request_timeout;
|
};
|
||||||
if tokio::runtime::Handle::try_current().is_ok() {
|
let body = request.body.unwrap_or_default().into_bytes();
|
||||||
std::thread::spawn(move || {
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
execute_runtime_owned_workspace_http(
|
if !body.is_empty() {
|
||||||
&base_url,
|
headers.insert(
|
||||||
&workspace_id,
|
reqwest::header::CONTENT_TYPE,
|
||||||
&runtime_id,
|
reqwest::header::HeaderValue::from_static("application/json"),
|
||||||
&worker_id,
|
);
|
||||||
request_source_signer.as_ref(),
|
|
||||||
request_source_audience.as_deref(),
|
|
||||||
request_timeout,
|
|
||||||
permission,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.join()
|
|
||||||
.map_err(|_| {
|
|
||||||
WorkspaceClientError::Request("workspace request thread panicked".to_string())
|
|
||||||
})?
|
|
||||||
} else {
|
|
||||||
execute_runtime_owned_workspace_http(
|
|
||||||
&self.base_url,
|
|
||||||
&self.workspace_id,
|
|
||||||
&self.runtime_id,
|
|
||||||
&self.worker_id,
|
|
||||||
self.request_source_signer.as_ref(),
|
|
||||||
self.request_source_audience.as_deref(),
|
|
||||||
self.request_timeout,
|
|
||||||
permission,
|
|
||||||
request,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
let request_label = format!("{method} {}", request.path);
|
||||||
|
let response = self
|
||||||
|
.request_client
|
||||||
|
.execute_blocking(RuntimeWorkspaceRequest {
|
||||||
|
method,
|
||||||
|
path_and_query: request.path,
|
||||||
|
body,
|
||||||
|
headers,
|
||||||
|
permission: permission.to_string(),
|
||||||
|
worker_id: Some(self.worker_id.clone()),
|
||||||
|
timeout: self.request_timeout,
|
||||||
|
max_response_bytes: 8 * 1024 * 1024,
|
||||||
|
})
|
||||||
|
.map_err(|error| {
|
||||||
|
WorkspaceClientError::Request(format!("{request_label} failed: {error}"))
|
||||||
|
})?;
|
||||||
|
let body = String::from_utf8(response.body)
|
||||||
|
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
||||||
|
Ok(WorkspaceResponse {
|
||||||
|
status: response.status.as_u16(),
|
||||||
|
body,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -420,8 +421,8 @@ impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
|
|||||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
formatter
|
formatter
|
||||||
.debug_struct("RuntimeOwnedWorkspaceClient")
|
.debug_struct("RuntimeOwnedWorkspaceClient")
|
||||||
.field("workspace_id", &self.workspace_id)
|
.field("workspace_id", &self.request_client.workspace_id())
|
||||||
.field("base_url", &self.base_url)
|
.field("base_url", &self.request_client.base_url())
|
||||||
.field("source", &"Runtime-owned")
|
.field("source", &"Runtime-owned")
|
||||||
.field(
|
.field(
|
||||||
"worker_remove",
|
"worker_remove",
|
||||||
@@ -433,7 +434,7 @@ impl std::fmt::Debug for RuntimeOwnedWorkspaceClient {
|
|||||||
|
|
||||||
impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
||||||
fn workspace_id(&self) -> Option<&str> {
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
Some(&self.workspace_id)
|
Some(self.request_client.workspace_id())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn kind(&self) -> &str {
|
fn kind(&self) -> &str {
|
||||||
@@ -588,117 +589,6 @@ fn percent_encode_query(value: &str) -> String {
|
|||||||
encoded
|
encoded
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_runtime_owned_workspace_http(
|
|
||||||
base_url: &str,
|
|
||||||
workspace_id: &str,
|
|
||||||
runtime_id: &str,
|
|
||||||
worker_id: &str,
|
|
||||||
request_source_signer: Option<&RuntimeRequestSourceSigner>,
|
|
||||||
request_source_audience: Option<&str>,
|
|
||||||
request_timeout: Option<Duration>,
|
|
||||||
permission: &'static str,
|
|
||||||
request: WorkspaceRequest,
|
|
||||||
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
|
||||||
if !request.path.starts_with('/') || request.path.starts_with("//") {
|
|
||||||
return Err(WorkspaceClientError::InvalidPath(request.path));
|
|
||||||
}
|
|
||||||
let url = format!("{base_url}{}", request.path);
|
|
||||||
let method = match request.method {
|
|
||||||
WorkspaceRequestMethod::Get => reqwest::Method::GET,
|
|
||||||
WorkspaceRequestMethod::Post => reqwest::Method::POST,
|
|
||||||
WorkspaceRequestMethod::Put => reqwest::Method::PUT,
|
|
||||||
WorkspaceRequestMethod::Patch => reqwest::Method::PATCH,
|
|
||||||
WorkspaceRequestMethod::Delete => reqwest::Method::DELETE,
|
|
||||||
};
|
|
||||||
let client = reqwest::blocking::Client::builder()
|
|
||||||
.timeout(request_timeout)
|
|
||||||
.build()
|
|
||||||
.map_err(|error| {
|
|
||||||
WorkspaceClientError::Unavailable(format!(
|
|
||||||
"failed to build Workspace API HTTP client: {}",
|
|
||||||
reqwest_error_chain(&error)
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let request_label = format!("{method} {}", request.path);
|
|
||||||
let body = request.body.unwrap_or_default();
|
|
||||||
let mut request_builder = client
|
|
||||||
.request(method.clone(), url)
|
|
||||||
.header("x-yoi-runtime-id", runtime_id)
|
|
||||||
.header("x-yoi-worker-id", worker_id);
|
|
||||||
if let Some(signer) = request_source_signer {
|
|
||||||
let audience = request_source_audience.ok_or_else(|| {
|
|
||||||
WorkspaceClientError::Request(
|
|
||||||
"runtime request proof audience is unavailable".to_owned(),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
let proof = signer
|
|
||||||
.issue(
|
|
||||||
audience,
|
|
||||||
workspace_id,
|
|
||||||
Some(worker_id),
|
|
||||||
permission,
|
|
||||||
method.as_str(),
|
|
||||||
&request.path,
|
|
||||||
body.as_bytes(),
|
|
||||||
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
|
|
||||||
30,
|
|
||||||
)
|
|
||||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
|
||||||
request_builder = request_builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
|
|
||||||
}
|
|
||||||
if !body.is_empty() {
|
|
||||||
request_builder = request_builder
|
|
||||||
.header(reqwest::header::CONTENT_TYPE, "application/json")
|
|
||||||
.body(body);
|
|
||||||
}
|
|
||||||
let response = request_builder
|
|
||||||
.send()
|
|
||||||
.map_err(|error| workspace_http_error(&request_label, "waiting for response", error))?;
|
|
||||||
let status = response.status().as_u16();
|
|
||||||
let body = response
|
|
||||||
.text()
|
|
||||||
.map_err(|error| workspace_http_error(&request_label, "reading response body", error))?;
|
|
||||||
Ok(WorkspaceResponse { status, body })
|
|
||||||
}
|
|
||||||
|
|
||||||
fn workspace_http_error(
|
|
||||||
request_label: &str,
|
|
||||||
stage: &str,
|
|
||||||
error: reqwest::Error,
|
|
||||||
) -> WorkspaceClientError {
|
|
||||||
let details = reqwest_error_chain(&error);
|
|
||||||
if error.is_timeout() {
|
|
||||||
WorkspaceClientError::Request(format!(
|
|
||||||
"Workspace API {request_label} timed out while {stage}: {details}"
|
|
||||||
))
|
|
||||||
} else if error.is_connect() {
|
|
||||||
WorkspaceClientError::Unavailable(format!(
|
|
||||||
"Workspace API {request_label} could not connect while {stage}: {details}"
|
|
||||||
))
|
|
||||||
} else {
|
|
||||||
WorkspaceClientError::Request(format!(
|
|
||||||
"Workspace API {request_label} transport failed while {stage}: {details}"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reqwest_error_chain(error: &reqwest::Error) -> String {
|
|
||||||
let mut details = error.to_string();
|
|
||||||
let mut source = std::error::Error::source(error);
|
|
||||||
for _ in 0..4 {
|
|
||||||
let Some(current) = source else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
let current_text = current.to_string();
|
|
||||||
if !current_text.is_empty() && !details.ends_with(¤t_text) {
|
|
||||||
details.push_str(": ");
|
|
||||||
details.push_str(¤t_text);
|
|
||||||
}
|
|
||||||
source = std::error::Error::source(current);
|
|
||||||
}
|
|
||||||
details
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum RuntimeWorkerMutationForwardError {
|
pub enum RuntimeWorkerMutationForwardError {
|
||||||
#[error(transparent)]
|
#[error(transparent)]
|
||||||
@@ -722,9 +612,9 @@ fn unix_now_seconds() -> u64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
WorkerMutationSourceExpectation, decode_runtime_request_source_claims,
|
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, WorkerMutationSourceExpectation,
|
||||||
decode_worker_mutation_source_claims, request_body_digest,
|
decode_runtime_request_source_claims, decode_worker_mutation_source_claims,
|
||||||
verify_worker_mutation_source_proof,
|
request_body_digest, verify_worker_mutation_source_proof,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1132,7 +1022,12 @@ mod tests {
|
|||||||
&identity,
|
&identity,
|
||||||
scope,
|
scope,
|
||||||
"worker-source",
|
"worker-source",
|
||||||
format!("http://{address}"),
|
RuntimeWorkspaceRequestClient::new(
|
||||||
|
"workspace-a",
|
||||||
|
format!("http://{address}"),
|
||||||
|
"runtime-a",
|
||||||
|
)
|
||||||
|
.with_runtime_request_source(&identity, "server-a"),
|
||||||
);
|
);
|
||||||
let response = forwarder
|
let response = forwarder
|
||||||
.execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker")
|
.execute_worker_remove("runtime-target", "worker-target", "retire obsolete Worker")
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,360 @@
|
|||||||
|
use std::error::Error as _;
|
||||||
|
use std::io::Read;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use futures::StreamExt;
|
||||||
|
use reqwest::header::HeaderMap;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use crate::auth::{
|
||||||
|
RUNTIME_REQUEST_SOURCE_PROOF_HEADER, RuntimeAuthError, RuntimeIdentityMaterial,
|
||||||
|
RuntimeRequestSourceSigner,
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_REQUEST_PROOF_TTL_SECONDS: u64 = 60;
|
||||||
|
const RUNTIME_ID_HEADER: &str = "x-yoi-runtime-id";
|
||||||
|
const WORKER_ID_HEADER: &str = "x-yoi-worker-id";
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct RuntimeWorkspaceRequestClient {
|
||||||
|
workspace_id: String,
|
||||||
|
base_url: String,
|
||||||
|
runtime_id: String,
|
||||||
|
request_source: Option<(RuntimeRequestSourceSigner, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct RuntimeWorkspaceRequest {
|
||||||
|
pub method: reqwest::Method,
|
||||||
|
pub path_and_query: String,
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
pub headers: HeaderMap,
|
||||||
|
pub permission: String,
|
||||||
|
pub worker_id: Option<String>,
|
||||||
|
pub timeout: Option<Duration>,
|
||||||
|
pub max_response_bytes: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct RuntimeWorkspaceResponse {
|
||||||
|
pub status: reqwest::StatusCode,
|
||||||
|
pub headers: HeaderMap,
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub(crate) enum RuntimeWorkspaceRequestError {
|
||||||
|
#[error("invalid Workspace request: {0}")]
|
||||||
|
InvalidRequest(String),
|
||||||
|
#[error("failed to sign Workspace request: {0}")]
|
||||||
|
Sign(#[from] RuntimeAuthError),
|
||||||
|
#[error("Workspace request failed: {message}")]
|
||||||
|
Transport { message: String, timeout: bool },
|
||||||
|
#[error("Workspace response exceeded {max_response_bytes} bytes")]
|
||||||
|
ResponseTooLarge { max_response_bytes: usize },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeWorkspaceRequestError {
|
||||||
|
fn transport(error: reqwest::Error) -> Self {
|
||||||
|
let timeout = error.is_timeout();
|
||||||
|
Self::Transport {
|
||||||
|
message: reqwest_error_chain(&error),
|
||||||
|
timeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_timeout(&self) -> bool {
|
||||||
|
matches!(self, Self::Transport { timeout: true, .. })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RuntimeWorkspaceRequestClient {
|
||||||
|
pub fn new(
|
||||||
|
workspace_id: impl Into<String>,
|
||||||
|
base_url: impl Into<String>,
|
||||||
|
runtime_id: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
workspace_id: workspace_id.into(),
|
||||||
|
base_url: base_url.into().trim_end_matches('/').to_string(),
|
||||||
|
runtime_id: runtime_id.into(),
|
||||||
|
request_source: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_runtime_request_source(
|
||||||
|
mut self,
|
||||||
|
identity: &RuntimeIdentityMaterial,
|
||||||
|
audience: impl Into<String>,
|
||||||
|
) -> Self {
|
||||||
|
self.request_source = Some((
|
||||||
|
RuntimeRequestSourceSigner::from_identity(identity),
|
||||||
|
audience.into(),
|
||||||
|
));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn workspace_id(&self) -> &str {
|
||||||
|
&self.workspace_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn base_url(&self) -> &str {
|
||||||
|
&self.base_url
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runtime_id(&self) -> &str {
|
||||||
|
&self.runtime_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn audience(&self) -> Option<&str> {
|
||||||
|
self.request_source
|
||||||
|
.as_ref()
|
||||||
|
.map(|(_, audience)| audience.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn matches_workspace(&self, workspace_id: &str, base_url: &str) -> bool {
|
||||||
|
self.workspace_id == workspace_id
|
||||||
|
&& self.base_url.trim_end_matches('/') == base_url.trim_end_matches('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn execute(
|
||||||
|
&self,
|
||||||
|
request: RuntimeWorkspaceRequest,
|
||||||
|
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
|
||||||
|
let prepared = self.prepare(&request)?;
|
||||||
|
let mut client_builder = reqwest::Client::builder();
|
||||||
|
if let Some(timeout) = request.timeout {
|
||||||
|
client_builder = client_builder.timeout(timeout);
|
||||||
|
}
|
||||||
|
let client = client_builder
|
||||||
|
.build()
|
||||||
|
.map_err(RuntimeWorkspaceRequestError::transport)?;
|
||||||
|
let mut builder = client
|
||||||
|
.request(request.method, prepared.url)
|
||||||
|
.headers(request.headers)
|
||||||
|
.header(RUNTIME_ID_HEADER, &self.runtime_id);
|
||||||
|
if let Some(worker_id) = request.worker_id.as_deref() {
|
||||||
|
builder = builder.header(WORKER_ID_HEADER, worker_id);
|
||||||
|
}
|
||||||
|
if let Some(proof) = prepared.proof {
|
||||||
|
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
|
||||||
|
}
|
||||||
|
if !request.body.is_empty() {
|
||||||
|
builder = builder.body(request.body);
|
||||||
|
}
|
||||||
|
let response = builder
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(RuntimeWorkspaceRequestError::transport)?;
|
||||||
|
let status = response.status();
|
||||||
|
let headers = response.headers().clone();
|
||||||
|
if response
|
||||||
|
.content_length()
|
||||||
|
.is_some_and(|size| size > request.max_response_bytes as u64)
|
||||||
|
{
|
||||||
|
return Err(RuntimeWorkspaceRequestError::ResponseTooLarge {
|
||||||
|
max_response_bytes: request.max_response_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut body = Vec::new();
|
||||||
|
let mut stream = response.bytes_stream();
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let chunk = chunk.map_err(RuntimeWorkspaceRequestError::transport)?;
|
||||||
|
if body.len().saturating_add(chunk.len()) > request.max_response_bytes {
|
||||||
|
return Err(RuntimeWorkspaceRequestError::ResponseTooLarge {
|
||||||
|
max_response_bytes: request.max_response_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
body.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
Ok(RuntimeWorkspaceResponse {
|
||||||
|
status,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn execute_blocking(
|
||||||
|
&self,
|
||||||
|
request: RuntimeWorkspaceRequest,
|
||||||
|
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
|
||||||
|
let client = self.clone();
|
||||||
|
std::thread::spawn(move || client.execute_blocking_inner(request))
|
||||||
|
.join()
|
||||||
|
.map_err(|_| RuntimeWorkspaceRequestError::Transport {
|
||||||
|
message: "Workspace request thread panicked".to_string(),
|
||||||
|
timeout: false,
|
||||||
|
})?
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_blocking_inner(
|
||||||
|
&self,
|
||||||
|
request: RuntimeWorkspaceRequest,
|
||||||
|
) -> Result<RuntimeWorkspaceResponse, RuntimeWorkspaceRequestError> {
|
||||||
|
let prepared = self.prepare(&request)?;
|
||||||
|
let mut client_builder = reqwest::blocking::Client::builder();
|
||||||
|
if let Some(timeout) = request.timeout {
|
||||||
|
client_builder = client_builder.timeout(timeout);
|
||||||
|
}
|
||||||
|
let client = client_builder
|
||||||
|
.build()
|
||||||
|
.map_err(RuntimeWorkspaceRequestError::transport)?;
|
||||||
|
let mut builder = client
|
||||||
|
.request(request.method, prepared.url)
|
||||||
|
.headers(request.headers)
|
||||||
|
.header(RUNTIME_ID_HEADER, &self.runtime_id);
|
||||||
|
if let Some(worker_id) = request.worker_id.as_deref() {
|
||||||
|
builder = builder.header(WORKER_ID_HEADER, worker_id);
|
||||||
|
}
|
||||||
|
if let Some(proof) = prepared.proof {
|
||||||
|
builder = builder.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
|
||||||
|
}
|
||||||
|
if !request.body.is_empty() {
|
||||||
|
builder = builder.body(request.body);
|
||||||
|
}
|
||||||
|
let response = builder
|
||||||
|
.send()
|
||||||
|
.map_err(RuntimeWorkspaceRequestError::transport)?;
|
||||||
|
let status = response.status();
|
||||||
|
let headers = response.headers().clone();
|
||||||
|
if response
|
||||||
|
.content_length()
|
||||||
|
.is_some_and(|size| size > request.max_response_bytes as u64)
|
||||||
|
{
|
||||||
|
return Err(RuntimeWorkspaceRequestError::ResponseTooLarge {
|
||||||
|
max_response_bytes: request.max_response_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let limit = u64::try_from(request.max_response_bytes)
|
||||||
|
.unwrap_or(u64::MAX)
|
||||||
|
.saturating_add(1);
|
||||||
|
let mut body = Vec::new();
|
||||||
|
response
|
||||||
|
.take(limit)
|
||||||
|
.read_to_end(&mut body)
|
||||||
|
.map_err(|error| RuntimeWorkspaceRequestError::Transport {
|
||||||
|
message: error.to_string(),
|
||||||
|
timeout: false,
|
||||||
|
})?;
|
||||||
|
if body.len() > request.max_response_bytes {
|
||||||
|
return Err(RuntimeWorkspaceRequestError::ResponseTooLarge {
|
||||||
|
max_response_bytes: request.max_response_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(RuntimeWorkspaceResponse {
|
||||||
|
status,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare(
|
||||||
|
&self,
|
||||||
|
request: &RuntimeWorkspaceRequest,
|
||||||
|
) -> Result<PreparedRuntimeWorkspaceRequest, RuntimeWorkspaceRequestError> {
|
||||||
|
if !request.path_and_query.starts_with('/') || request.path_and_query.starts_with("//") {
|
||||||
|
return Err(RuntimeWorkspaceRequestError::InvalidRequest(
|
||||||
|
"path must start with '/'".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let url = reqwest::Url::parse(&format!("{}{}", self.base_url, request.path_and_query))
|
||||||
|
.map_err(|error| RuntimeWorkspaceRequestError::InvalidRequest(error.to_string()))?;
|
||||||
|
let mut request_target = url.path().to_string();
|
||||||
|
if let Some(query) = url.query() {
|
||||||
|
request_target.push('?');
|
||||||
|
request_target.push_str(query);
|
||||||
|
}
|
||||||
|
let proof = self
|
||||||
|
.request_source
|
||||||
|
.as_ref()
|
||||||
|
.map(|(signer, audience)| {
|
||||||
|
signer.issue(
|
||||||
|
audience,
|
||||||
|
&self.workspace_id,
|
||||||
|
request.worker_id.as_deref(),
|
||||||
|
&request.permission,
|
||||||
|
request.method.as_str(),
|
||||||
|
&request_target,
|
||||||
|
&request.body,
|
||||||
|
unix_now_seconds(),
|
||||||
|
DEFAULT_REQUEST_PROOF_TTL_SECONDS,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
Ok(PreparedRuntimeWorkspaceRequest { url, proof })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PreparedRuntimeWorkspaceRequest {
|
||||||
|
url: reqwest::Url,
|
||||||
|
proof: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reqwest_error_chain(error: &reqwest::Error) -> String {
|
||||||
|
let mut message = error.to_string();
|
||||||
|
let mut source = error.source();
|
||||||
|
while let Some(error) = source {
|
||||||
|
message.push_str(": ");
|
||||||
|
message.push_str(&error.to_string());
|
||||||
|
source = error.source();
|
||||||
|
}
|
||||||
|
message
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now_seconds() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| i64::try_from(duration.as_secs()).unwrap_or(i64::MAX))
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::auth::decode_runtime_request_source_claims;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn route_issues_workspace_scoped_request_proof() {
|
||||||
|
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
|
||||||
|
let client = RuntimeWorkspaceRequestClient::new(
|
||||||
|
"workspace-a",
|
||||||
|
"https://workspace.example.test/",
|
||||||
|
"runtime-a",
|
||||||
|
)
|
||||||
|
.with_runtime_request_source(&identity, "workspace-server-a");
|
||||||
|
let request = RuntimeWorkspaceRequest {
|
||||||
|
method: reqwest::Method::GET,
|
||||||
|
path_and_query: "/api/w/workspace-a/runtime-config?profile=coder".to_string(),
|
||||||
|
body: Vec::new(),
|
||||||
|
headers: HeaderMap::new(),
|
||||||
|
permission: "backend.resource.fetch".to_string(),
|
||||||
|
worker_id: None,
|
||||||
|
timeout: Some(Duration::from_secs(5)),
|
||||||
|
max_response_bytes: 1024,
|
||||||
|
};
|
||||||
|
|
||||||
|
let prepared = client.prepare(&request).unwrap();
|
||||||
|
let claims = decode_runtime_request_source_claims(&prepared.proof.unwrap()).unwrap();
|
||||||
|
assert_eq!(claims.aud, "workspace-server-a");
|
||||||
|
assert_eq!(claims.workspace_id, "workspace-a");
|
||||||
|
assert_eq!(claims.worker_id, None);
|
||||||
|
assert_eq!(claims.method, "GET");
|
||||||
|
assert_eq!(
|
||||||
|
claims.path,
|
||||||
|
"/api/w/workspace-a/runtime-config?profile=coder"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn route_matches_only_its_workspace_and_backend() {
|
||||||
|
let client = RuntimeWorkspaceRequestClient::new(
|
||||||
|
"workspace-a",
|
||||||
|
"https://workspace.example.test/",
|
||||||
|
"runtime-a",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(client.matches_workspace("workspace-a", "https://workspace.example.test"));
|
||||||
|
assert!(!client.matches_workspace("workspace-b", "https://workspace.example.test"));
|
||||||
|
assert!(!client.matches_workspace("workspace-a", "https://other.example.test"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -43,7 +43,6 @@ memory = { workspace = true }
|
|||||||
uuid = { workspace = true, features = ["v7"] }
|
uuid = { workspace = true, features = ["v7"] }
|
||||||
session-metrics = { workspace = true }
|
session-metrics = { workspace = true }
|
||||||
arc-swap = "1.9.1"
|
arc-swap = "1.9.1"
|
||||||
wasmtime = { version = "45.0.2", default-features = false, features = ["std", "runtime", "cranelift", "component-model"] }
|
|
||||||
tungstenite = { version = "0.28.0", default-features = false, features = ["handshake", "native-tls", "url"] }
|
tungstenite = { version = "0.28.0", default-features = false, features = ["handshake", "native-tls", "url"] }
|
||||||
tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["native-tls", "connect"] }
|
tokio-tungstenite = { version = "0.28.0", default-features = false, features = ["native-tls", "connect"] }
|
||||||
futures-util = { version = "0.3", features = ["sink"] }
|
futures-util = { version = "0.3", features = ["sink"] }
|
||||||
@@ -53,5 +52,3 @@ dotenv = "0.15.0"
|
|||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
serial_test = "3.4.0"
|
serial_test = "3.4.0"
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
wat = "1.241.2"
|
|
||||||
yoi-plugin-pdk = { workspace = true }
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ pub struct PreparedWorker<C: LlmClient, St: Store> {
|
|||||||
pub struct BootstrappedWorker {
|
pub struct BootstrappedWorker {
|
||||||
pub handle: WorkerHandle,
|
pub handle: WorkerHandle,
|
||||||
pub shutdown: ShutdownReceiver,
|
pub shutdown: ShutdownReceiver,
|
||||||
|
pub controller_task: tokio::task::JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -227,7 +228,7 @@ where
|
|||||||
runtime_base,
|
runtime_base,
|
||||||
bash_output_dir,
|
bash_output_dir,
|
||||||
} => {
|
} => {
|
||||||
WorkerController::spawn_with_transport(
|
WorkerController::spawn_with_transport_owned(
|
||||||
worker,
|
worker,
|
||||||
&runtime_base,
|
&runtime_base,
|
||||||
&bash_output_dir,
|
&bash_output_dir,
|
||||||
@@ -239,7 +240,7 @@ where
|
|||||||
run_dir,
|
run_dir,
|
||||||
bash_output_dir,
|
bash_output_dir,
|
||||||
} => {
|
} => {
|
||||||
WorkerController::spawn_runtime_managed_run_with_transport(
|
WorkerController::spawn_runtime_managed_run_with_transport_owned(
|
||||||
worker,
|
worker,
|
||||||
&run_dir,
|
&run_dir,
|
||||||
&bash_output_dir,
|
&bash_output_dir,
|
||||||
@@ -250,7 +251,11 @@ where
|
|||||||
};
|
};
|
||||||
|
|
||||||
match controller {
|
match controller {
|
||||||
Ok((handle, shutdown)) => Ok(BootstrappedWorker { handle, shutdown }),
|
Ok((handle, shutdown, controller_task)) => Ok(BootstrappedWorker {
|
||||||
|
handle,
|
||||||
|
shutdown,
|
||||||
|
controller_task,
|
||||||
|
}),
|
||||||
Err(source) => {
|
Err(source) => {
|
||||||
let cleanup_failed = match cleanup_session {
|
let cleanup_failed = match cleanup_session {
|
||||||
Some(session) => session.close().await.is_err(),
|
Some(session) => session.close().await.is_err(),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub(crate) mod metrics_tracker;
|
pub(crate) mod metrics_tracker;
|
||||||
pub(crate) mod prune;
|
pub(crate) mod prune;
|
||||||
pub(crate) mod state;
|
pub(crate) mod state;
|
||||||
|
pub(crate) mod telemetry;
|
||||||
pub(crate) mod token_counter;
|
pub(crate) mod token_counter;
|
||||||
pub(crate) mod usage_tracker;
|
pub(crate) mod usage_tracker;
|
||||||
pub(crate) mod worker;
|
pub(crate) mod worker;
|
||||||
|
|||||||
+309
-140
@@ -1,109 +1,236 @@
|
|||||||
//! Shared state for compaction decisions.
|
use std::sync::Mutex;
|
||||||
//!
|
|
||||||
//! Holds the two configured thresholds and circuit-breaker / thrash-detection
|
|
||||||
//! flags shared between:
|
|
||||||
//! - `WorkerInterceptor` (reads `request_threshold` — the *safety net* for
|
|
||||||
//! between-requests yielding)
|
|
||||||
//! - `Worker::try_pre_run_compact` (reads `post_run_threshold` — the
|
|
||||||
//! *proactive* check before the next turn starts)
|
|
||||||
//! - `Worker::run()` / `resume()` (circuit breaker, thrash detection)
|
|
||||||
//!
|
|
||||||
//! Current occupancy (input-token count) is **not** stored here. The single
|
|
||||||
//! source of truth is `session_store::UsageRecord` (persisted per LLM call)
|
|
||||||
//! projected through `Worker::total_tokens()`. Callers pass the current
|
|
||||||
//! occupancy to `exceeds_*` at check time.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
use super::telemetry::CompactFailureCategory;
|
||||||
|
|
||||||
const MAX_COMPACT_FAILURES: usize = 3;
|
/// Process-local automatic compaction guard for the current logical run.
|
||||||
|
///
|
||||||
|
/// This guard is deliberately not persisted or reconstructed from session
|
||||||
|
/// history, compaction metrics, or replacement-segment state.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum AutomaticCompactGuard {
|
||||||
|
Ready,
|
||||||
|
SuppressedForCurrentRun {
|
||||||
|
failure_category: CompactFailureCategory,
|
||||||
|
},
|
||||||
|
AwaitingPostCompactRequest,
|
||||||
|
}
|
||||||
|
|
||||||
/// Shared mutable state for compaction decisions.
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum CompactionOutcome {
|
||||||
|
Succeeded,
|
||||||
|
Failed(CompactFailureCategory),
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum AutomaticCompactTrigger {
|
||||||
|
PreRun,
|
||||||
|
RequestThreshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decision returned by an atomic threshold/attempt-state evaluation.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum AutomaticCompactDecision {
|
||||||
|
Continue,
|
||||||
|
Start(AutomaticCompactTrigger),
|
||||||
|
Block(AutomaticCompactBlock),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typed reason why a provider request may not proceed.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum AutomaticCompactBlock {
|
||||||
|
/// An automatic attempt was already claimed for this logical run and has
|
||||||
|
/// not yet produced an outcome.
|
||||||
|
Attempted,
|
||||||
|
/// Compaction succeeded, but no post-compaction provider request has yet
|
||||||
|
/// committed a new occupancy UsageRecord.
|
||||||
|
Thrash,
|
||||||
|
/// This logical run already used its automatic attempt and it failed.
|
||||||
|
Failed(CompactFailureCategory),
|
||||||
|
/// This logical run's automatic attempt was cancelled. Cancellation is not
|
||||||
|
/// classified or counted as a compaction failure.
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct AutomaticCompactRuntimeState {
|
||||||
|
guard: AutomaticCompactGuard,
|
||||||
|
attempt_claimed: bool,
|
||||||
|
cancelled_attempt: bool,
|
||||||
|
pending_request_block: Option<AutomaticCompactBlock>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks automatic compaction thresholds and the current logical-run guard.
|
||||||
|
#[derive(Debug)]
|
||||||
pub(crate) struct CompactState {
|
pub(crate) struct CompactState {
|
||||||
/// Between-turns threshold (proactive). Checked before the next turn
|
/// Proactive threshold checked before a fresh user run starts.
|
||||||
/// starts. `None` disables the pre-run check.
|
compact_threshold: Option<u64>,
|
||||||
post_run_threshold: Option<u64>,
|
/// Safety threshold checked immediately before every provider request.
|
||||||
/// Between-requests threshold (safety net). Checked inside a turn
|
|
||||||
/// before each LLM request. `None` disables the request check.
|
|
||||||
request_threshold: Option<u64>,
|
request_threshold: Option<u64>,
|
||||||
/// Token budget retained verbatim at the tail after compaction.
|
|
||||||
retained_tokens: u64,
|
retained_tokens: u64,
|
||||||
/// Consecutive compact failures. At `MAX_COMPACT_FAILURES`, compaction is disabled.
|
runtime: Mutex<AutomaticCompactRuntimeState>,
|
||||||
consecutive_failures: AtomicUsize,
|
|
||||||
/// `true` immediately after a successful compact, cleared on next normal completion.
|
|
||||||
just_compacted: AtomicBool,
|
|
||||||
/// `true` when circuit breaker has tripped.
|
|
||||||
disabled: AtomicBool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CompactState {
|
impl CompactState {
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
post_run_threshold: Option<u64>,
|
compact_threshold: Option<u64>,
|
||||||
request_threshold: Option<u64>,
|
request_threshold: Option<u64>,
|
||||||
retained_tokens: u64,
|
retained_tokens: u64,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
post_run_threshold,
|
compact_threshold,
|
||||||
request_threshold,
|
request_threshold,
|
||||||
retained_tokens,
|
retained_tokens,
|
||||||
consecutive_failures: AtomicUsize::new(0),
|
runtime: Mutex::new(AutomaticCompactRuntimeState {
|
||||||
just_compacted: AtomicBool::new(false),
|
guard: AutomaticCompactGuard::Ready,
|
||||||
disabled: AtomicBool::new(false),
|
attempt_claimed: false,
|
||||||
|
cancelled_attempt: false,
|
||||||
|
pending_request_block: None,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configured between-requests threshold (if any).
|
|
||||||
pub(crate) fn request_threshold(&self) -> Option<u64> {
|
|
||||||
self.request_threshold
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Token budget retained verbatim at the tail after compaction.
|
|
||||||
pub(crate) fn retained_tokens(&self) -> u64 {
|
pub(crate) fn retained_tokens(&self) -> u64 {
|
||||||
self.retained_tokens
|
self.retained_tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether compaction has been disabled by the circuit breaker.
|
pub(crate) fn pre_run_eligible(&self, total_tokens: u64) -> bool {
|
||||||
pub(crate) fn is_disabled(&self) -> bool {
|
if !self
|
||||||
self.disabled.load(Ordering::Relaxed)
|
.compact_threshold
|
||||||
}
|
.is_some_and(|threshold| total_tokens > threshold)
|
||||||
|
{
|
||||||
/// Whether `current_tokens` exceeds the between-requests threshold.
|
return false;
|
||||||
/// Returns `false` when `request_threshold` is unset.
|
|
||||||
pub(crate) fn exceeds_request(&self, current_tokens: u64) -> bool {
|
|
||||||
self.request_threshold
|
|
||||||
.map(|t| current_tokens > t)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `current_tokens` exceeds the post-run threshold.
|
|
||||||
/// Returns `false` when `post_run_threshold` is unset.
|
|
||||||
pub(crate) fn exceeds_post_run(&self, current_tokens: u64) -> bool {
|
|
||||||
self.post_run_threshold
|
|
||||||
.map(|t| current_tokens > t)
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a compact just completed (for thrash detection).
|
|
||||||
pub(crate) fn just_compacted(&self) -> bool {
|
|
||||||
self.just_compacted.load(Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set or clear the just_compacted flag.
|
|
||||||
pub(crate) fn set_just_compacted(&self, val: bool) {
|
|
||||||
self.just_compacted.store(val, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a successful compaction: reset failure counter, set just_compacted.
|
|
||||||
pub(crate) fn record_compact_success(&self) {
|
|
||||||
self.consecutive_failures.store(0, Ordering::Relaxed);
|
|
||||||
self.just_compacted.store(true, Ordering::Relaxed);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a compaction failure. Disables compaction after MAX_COMPACT_FAILURES.
|
|
||||||
pub(crate) fn record_compact_failure(&self) {
|
|
||||||
let prev = self.consecutive_failures.fetch_add(1, Ordering::Relaxed);
|
|
||||||
if prev + 1 >= MAX_COMPACT_FAILURES {
|
|
||||||
self.disabled.store(true, Ordering::Relaxed);
|
|
||||||
}
|
}
|
||||||
|
let runtime = self.lock_runtime();
|
||||||
|
runtime.guard == AutomaticCompactGuard::Ready && !runtime.attempt_claimed
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a fresh logical run. Pause/resume paths must not call this.
|
||||||
|
pub(crate) fn begin_logical_run(&self) {
|
||||||
|
self.clear_logical_run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears per-run state after a terminal run outcome.
|
||||||
|
pub(crate) fn finish_logical_run(&self) {
|
||||||
|
self.clear_logical_run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically evaluates the proactive threshold and claims this logical
|
||||||
|
/// run's automatic attempt when eligible.
|
||||||
|
pub(crate) fn evaluate_pre_run(&self, total_tokens: u64) -> AutomaticCompactDecision {
|
||||||
|
if !self
|
||||||
|
.compact_threshold
|
||||||
|
.is_some_and(|threshold| total_tokens > threshold)
|
||||||
|
{
|
||||||
|
return AutomaticCompactDecision::Continue;
|
||||||
|
}
|
||||||
|
self.claim_attempt(AutomaticCompactTrigger::PreRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Atomically evaluates the request safety threshold and either claims an
|
||||||
|
/// automatic attempt or returns the typed reason the request must stop.
|
||||||
|
pub(crate) fn evaluate_request(&self, total_tokens: u64) -> AutomaticCompactDecision {
|
||||||
|
if !self
|
||||||
|
.request_threshold
|
||||||
|
.is_some_and(|threshold| total_tokens > threshold)
|
||||||
|
{
|
||||||
|
return AutomaticCompactDecision::Continue;
|
||||||
|
}
|
||||||
|
self.claim_attempt(AutomaticCompactTrigger::RequestThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claims a hook-originated compaction yield under the same guard used by
|
||||||
|
/// threshold evaluation. This exists even in manual-only configurations.
|
||||||
|
pub(crate) fn claim_hook_yield(&self) -> AutomaticCompactDecision {
|
||||||
|
self.claim_attempt(AutomaticCompactTrigger::RequestThreshold)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn has_claimed_attempt(&self) -> bool {
|
||||||
|
self.lock_runtime().attempt_claimed
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn record_request_block(&self, block: AutomaticCompactBlock) {
|
||||||
|
self.lock_runtime().pending_request_block = Some(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Completes the currently claimed automatic attempt exactly once.
|
||||||
|
pub(crate) fn complete_automatic(&self, outcome: CompactionOutcome) -> bool {
|
||||||
|
let mut runtime = self.lock_runtime();
|
||||||
|
if !runtime.attempt_claimed
|
||||||
|
|| runtime.guard != AutomaticCompactGuard::Ready
|
||||||
|
|| runtime.cancelled_attempt
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
CompactionOutcome::Succeeded => {
|
||||||
|
runtime.guard = AutomaticCompactGuard::AwaitingPostCompactRequest;
|
||||||
|
}
|
||||||
|
CompactionOutcome::Failed(failure_category) => {
|
||||||
|
runtime.guard = AutomaticCompactGuard::SuppressedForCurrentRun { failure_category };
|
||||||
|
}
|
||||||
|
CompactionOutcome::Cancelled => {
|
||||||
|
runtime.cancelled_attempt = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-arms automatic compaction only after the first real provider request
|
||||||
|
/// following successful compaction has a durably committed UsageRecord.
|
||||||
|
pub(crate) fn post_compact_request_committed(&self) {
|
||||||
|
let mut runtime = self.lock_runtime();
|
||||||
|
if runtime.guard == AutomaticCompactGuard::AwaitingPostCompactRequest {
|
||||||
|
runtime.guard = AutomaticCompactGuard::Ready;
|
||||||
|
runtime.attempt_claimed = false;
|
||||||
|
runtime.cancelled_attempt = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn take_pending_request_block(&self) -> Option<AutomaticCompactBlock> {
|
||||||
|
self.lock_runtime().pending_request_block.take()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claim_attempt(&self, trigger: AutomaticCompactTrigger) -> AutomaticCompactDecision {
|
||||||
|
let mut runtime = self.lock_runtime();
|
||||||
|
match runtime.guard {
|
||||||
|
AutomaticCompactGuard::Ready if !runtime.attempt_claimed => {
|
||||||
|
runtime.attempt_claimed = true;
|
||||||
|
AutomaticCompactDecision::Start(trigger)
|
||||||
|
}
|
||||||
|
AutomaticCompactGuard::Ready if runtime.cancelled_attempt => {
|
||||||
|
AutomaticCompactDecision::Block(AutomaticCompactBlock::Cancelled)
|
||||||
|
}
|
||||||
|
AutomaticCompactGuard::Ready => {
|
||||||
|
AutomaticCompactDecision::Block(AutomaticCompactBlock::Attempted)
|
||||||
|
}
|
||||||
|
AutomaticCompactGuard::SuppressedForCurrentRun { failure_category } => {
|
||||||
|
AutomaticCompactDecision::Block(AutomaticCompactBlock::Failed(failure_category))
|
||||||
|
}
|
||||||
|
AutomaticCompactGuard::AwaitingPostCompactRequest => {
|
||||||
|
AutomaticCompactDecision::Block(AutomaticCompactBlock::Thrash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear_logical_run(&self) {
|
||||||
|
let mut runtime = self.lock_runtime();
|
||||||
|
runtime.guard = AutomaticCompactGuard::Ready;
|
||||||
|
runtime.attempt_claimed = false;
|
||||||
|
runtime.cancelled_attempt = false;
|
||||||
|
runtime.pending_request_block = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_runtime(&self) -> std::sync::MutexGuard<'_, AutomaticCompactRuntimeState> {
|
||||||
|
self.runtime
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn guard(&self) -> AutomaticCompactGuard {
|
||||||
|
self.lock_runtime().guard
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,81 +238,123 @@ impl CompactState {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
const FAILURE: CompactFailureCategory = CompactFailureCategory::Storage;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn both_thresholds_configured() {
|
fn automatic_failure_suppresses_only_current_logical_run() {
|
||||||
let state = CompactState::new(Some(80_000), Some(90_000), 8_000);
|
let state = CompactState::new(Some(10), Some(10), 2);
|
||||||
assert_eq!(state.request_threshold(), Some(90_000));
|
assert_eq!(
|
||||||
assert_eq!(state.retained_tokens(), 8_000);
|
state.evaluate_pre_run(11),
|
||||||
|
AutomaticCompactDecision::Start(AutomaticCompactTrigger::PreRun)
|
||||||
|
);
|
||||||
|
assert!(state.complete_automatic(CompactionOutcome::Failed(FAILURE)));
|
||||||
|
assert_eq!(
|
||||||
|
state.guard(),
|
||||||
|
AutomaticCompactGuard::SuppressedForCurrentRun {
|
||||||
|
failure_category: FAILURE
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.evaluate_request(10),
|
||||||
|
AutomaticCompactDecision::Continue,
|
||||||
|
"a failed proactive compact still permits a request below the safety threshold"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
state.evaluate_request(11),
|
||||||
|
AutomaticCompactDecision::Block(AutomaticCompactBlock::Failed(FAILURE))
|
||||||
|
);
|
||||||
|
|
||||||
assert!(!state.exceeds_request(70_000));
|
state.begin_logical_run();
|
||||||
assert!(!state.exceeds_post_run(70_000));
|
assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
|
||||||
|
assert_eq!(
|
||||||
assert!(!state.exceeds_request(85_000));
|
state.evaluate_pre_run(11),
|
||||||
assert!(state.exceeds_post_run(85_000));
|
AutomaticCompactDecision::Start(AutomaticCompactTrigger::PreRun)
|
||||||
|
);
|
||||||
assert!(state.exceeds_request(95_000));
|
|
||||||
assert!(state.exceeds_post_run(95_000));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn post_run_only() {
|
fn claimed_attempt_cannot_be_started_twice() {
|
||||||
let state = CompactState::new(Some(80_000), None, 8_000);
|
let state = CompactState::new(Some(10), Some(10), 2);
|
||||||
// request check always false when threshold is None.
|
assert!(matches!(
|
||||||
assert!(!state.exceeds_request(1_000_000));
|
state.evaluate_pre_run(11),
|
||||||
assert!(state.exceeds_post_run(85_000));
|
AutomaticCompactDecision::Start(_)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
state.evaluate_request(11),
|
||||||
|
AutomaticCompactDecision::Block(AutomaticCompactBlock::Attempted)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn request_only() {
|
fn cancellation_consumes_run_attempt_without_becoming_failure() {
|
||||||
let state = CompactState::new(None, Some(90_000), 8_000);
|
let state = CompactState::new(None, Some(10), 2);
|
||||||
assert!(!state.exceeds_post_run(1_000_000));
|
assert!(matches!(
|
||||||
assert!(state.exceeds_request(95_000));
|
state.evaluate_request(11),
|
||||||
|
AutomaticCompactDecision::Start(_)
|
||||||
|
));
|
||||||
|
assert!(state.complete_automatic(CompactionOutcome::Cancelled));
|
||||||
|
|
||||||
|
assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
|
||||||
|
assert_eq!(
|
||||||
|
state.evaluate_request(11),
|
||||||
|
AutomaticCompactDecision::Block(AutomaticCompactBlock::Cancelled)
|
||||||
|
);
|
||||||
|
state.begin_logical_run();
|
||||||
|
assert!(matches!(
|
||||||
|
state.evaluate_request(11),
|
||||||
|
AutomaticCompactDecision::Start(_)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn both_none_disables_all_checks() {
|
fn successful_compaction_requires_committed_request_before_rearming() {
|
||||||
let state = CompactState::new(None, None, 8_000);
|
let state = CompactState::new(None, Some(10), 2);
|
||||||
assert!(!state.exceeds_request(1_000_000));
|
assert!(matches!(
|
||||||
assert!(!state.exceeds_post_run(1_000_000));
|
state.evaluate_request(11),
|
||||||
|
AutomaticCompactDecision::Start(_)
|
||||||
|
));
|
||||||
|
assert!(state.complete_automatic(CompactionOutcome::Succeeded));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
state.evaluate_request(11),
|
||||||
|
AutomaticCompactDecision::Block(AutomaticCompactBlock::Thrash)
|
||||||
|
);
|
||||||
|
state.post_compact_request_committed();
|
||||||
|
assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
|
||||||
|
assert!(matches!(
|
||||||
|
state.evaluate_request(11),
|
||||||
|
AutomaticCompactDecision::Start(_)
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn circuit_breaker_trips_after_max_failures() {
|
fn pause_resume_preserves_guard_while_terminal_finish_clears_it() {
|
||||||
let state = CompactState::new(Some(80_000), Some(90_000), 8_000);
|
let state = CompactState::new(None, Some(10), 2);
|
||||||
assert!(!state.is_disabled());
|
assert!(matches!(
|
||||||
|
state.evaluate_request(11),
|
||||||
|
AutomaticCompactDecision::Start(_)
|
||||||
|
));
|
||||||
|
assert!(state.complete_automatic(CompactionOutcome::Failed(FAILURE)));
|
||||||
|
// Pause/resume deliberately performs no state transition.
|
||||||
|
assert!(matches!(
|
||||||
|
state.guard(),
|
||||||
|
AutomaticCompactGuard::SuppressedForCurrentRun { .. }
|
||||||
|
));
|
||||||
|
|
||||||
state.record_compact_failure();
|
state.finish_logical_run();
|
||||||
assert!(!state.is_disabled());
|
assert_eq!(state.guard(), AutomaticCompactGuard::Ready);
|
||||||
state.record_compact_failure();
|
|
||||||
assert!(!state.is_disabled());
|
|
||||||
state.record_compact_failure();
|
|
||||||
assert!(state.is_disabled());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn success_resets_failure_count() {
|
fn hook_yield_is_guarded_without_threshold_configuration() {
|
||||||
let state = CompactState::new(Some(80_000), Some(90_000), 8_000);
|
let state = CompactState::new(None, None, 2);
|
||||||
state.record_compact_failure();
|
assert!(matches!(
|
||||||
state.record_compact_failure();
|
state.claim_hook_yield(),
|
||||||
assert!(!state.is_disabled());
|
AutomaticCompactDecision::Start(_)
|
||||||
|
));
|
||||||
state.record_compact_success();
|
assert_eq!(
|
||||||
assert!(state.just_compacted());
|
state.claim_hook_yield(),
|
||||||
|
AutomaticCompactDecision::Block(AutomaticCompactBlock::Attempted)
|
||||||
state.record_compact_failure();
|
);
|
||||||
state.record_compact_failure();
|
|
||||||
assert!(!state.is_disabled());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn just_compacted_lifecycle() {
|
|
||||||
let state = CompactState::new(Some(80_000), Some(90_000), 8_000);
|
|
||||||
assert!(!state.just_compacted());
|
|
||||||
|
|
||||||
state.record_compact_success();
|
|
||||||
assert!(state.just_compacted());
|
|
||||||
|
|
||||||
state.set_just_compacted(false);
|
|
||||||
assert!(!state.just_compacted());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,441 @@
|
|||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use agen::token_counter::EstimateSource;
|
||||||
|
use agen::usage_record::UsageRecord;
|
||||||
|
use session_metrics::Metric;
|
||||||
|
use session_store::{SegmentId, SessionId};
|
||||||
|
|
||||||
|
use super::usage_tracker::{PostRequestMetric, UsageSnapshot};
|
||||||
|
|
||||||
|
const MAX_SAFE_INTEGER: u64 = (1_u64 << 53) - 1;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum CompactMode {
|
||||||
|
Manual,
|
||||||
|
Automatic,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompactMode {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Manual => "manual",
|
||||||
|
Self::Automatic => "automatic",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum CompactThresholdPolicy {
|
||||||
|
Manual,
|
||||||
|
PreRun,
|
||||||
|
RequestThreshold,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompactThresholdPolicy {
|
||||||
|
fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Manual => "manual",
|
||||||
|
Self::PreRun => "pre_run",
|
||||||
|
Self::RequestThreshold => "request_threshold",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum CompactFailureCategory {
|
||||||
|
Cancelled,
|
||||||
|
SummaryMissing,
|
||||||
|
SummaryTooLarge,
|
||||||
|
ResultContextTooLarge,
|
||||||
|
ActiveSegmentCommit,
|
||||||
|
Storage,
|
||||||
|
InternalWorker,
|
||||||
|
Preparation,
|
||||||
|
Other,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompactFailureCategory {
|
||||||
|
pub(crate) fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Cancelled => "cancelled",
|
||||||
|
Self::SummaryMissing => "summary_missing",
|
||||||
|
Self::SummaryTooLarge => "summary_too_large",
|
||||||
|
Self::ResultContextTooLarge => "result_context_too_large",
|
||||||
|
Self::ActiveSegmentCommit => "active_segment_commit",
|
||||||
|
Self::Storage => "storage",
|
||||||
|
Self::InternalWorker => "internal_worker",
|
||||||
|
Self::Preparation => "preparation",
|
||||||
|
Self::Other => "other",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct CompactAttempt {
|
||||||
|
correlation_id: String,
|
||||||
|
session_id: SessionId,
|
||||||
|
source_segment_id: SegmentId,
|
||||||
|
mode: CompactMode,
|
||||||
|
threshold_policy: CompactThresholdPolicy,
|
||||||
|
pre_context_tokens: u64,
|
||||||
|
pre_context_source: EstimateSource,
|
||||||
|
retained_token_budget: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CompactAttempt {
|
||||||
|
pub(crate) fn new(
|
||||||
|
correlation_id: String,
|
||||||
|
session_id: SessionId,
|
||||||
|
source_segment_id: SegmentId,
|
||||||
|
mode: CompactMode,
|
||||||
|
threshold_policy: CompactThresholdPolicy,
|
||||||
|
pre_context_tokens: u64,
|
||||||
|
pre_context_source: EstimateSource,
|
||||||
|
retained_token_budget: u64,
|
||||||
|
) -> Self {
|
||||||
|
debug_assert!(uuid::Uuid::parse_str(&correlation_id).is_ok());
|
||||||
|
Self {
|
||||||
|
correlation_id,
|
||||||
|
session_id,
|
||||||
|
source_segment_id,
|
||||||
|
mode,
|
||||||
|
threshold_policy,
|
||||||
|
pre_context_tokens,
|
||||||
|
pre_context_source,
|
||||||
|
retained_token_budget,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn correlation_id(&self) -> &str {
|
||||||
|
&self.correlation_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn start_metric(&self) -> Metric {
|
||||||
|
self.metric("compact.start")
|
||||||
|
.with_value(safe_metric_number(self.pre_context_tokens))
|
||||||
|
.with_dimension("occupancy_source", estimate_source(self.pre_context_source))
|
||||||
|
.with_dimension(
|
||||||
|
"retained_token_budget",
|
||||||
|
self.retained_token_budget.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn success_metrics(
|
||||||
|
&self,
|
||||||
|
result_segment_id: SegmentId,
|
||||||
|
elapsed: Duration,
|
||||||
|
stats: &CompactSuccessStats,
|
||||||
|
) -> Vec<Metric> {
|
||||||
|
let dimensions = self.base_dimensions();
|
||||||
|
let correlation_id = self.correlation_id.clone();
|
||||||
|
let mut metrics = vec![
|
||||||
|
metric_with_context("compact.finish", 1, &dimensions, &correlation_id)
|
||||||
|
.with_dimension("outcome", "succeeded")
|
||||||
|
.with_dimension("result_segment_id", result_segment_id.to_string())
|
||||||
|
.with_dimension("retained_items", stats.retained_items.to_string())
|
||||||
|
.with_dimension("summarized_items", stats.summarized_items.to_string()),
|
||||||
|
metric_with_context(
|
||||||
|
"compact.retained_tokens",
|
||||||
|
stats.retained_tokens,
|
||||||
|
&dimensions,
|
||||||
|
&correlation_id,
|
||||||
|
)
|
||||||
|
.with_dimension("source", estimate_source(stats.retained_tokens_source)),
|
||||||
|
metric_with_context(
|
||||||
|
"compact.overview_tokens",
|
||||||
|
stats.overview_tokens,
|
||||||
|
&dimensions,
|
||||||
|
&correlation_id,
|
||||||
|
),
|
||||||
|
metric_with_context(
|
||||||
|
"compact.summary_tokens",
|
||||||
|
stats.summary_tokens,
|
||||||
|
&dimensions,
|
||||||
|
&correlation_id,
|
||||||
|
),
|
||||||
|
metric_with_context(
|
||||||
|
"compact.auto_read_tokens",
|
||||||
|
stats.auto_read_tokens,
|
||||||
|
&dimensions,
|
||||||
|
&correlation_id,
|
||||||
|
),
|
||||||
|
metric_with_context(
|
||||||
|
"compact.result_context_tokens",
|
||||||
|
stats.result_context_tokens,
|
||||||
|
&dimensions,
|
||||||
|
&correlation_id,
|
||||||
|
)
|
||||||
|
.with_dimension("source", estimate_source(stats.result_context_source)),
|
||||||
|
metric_with_context(
|
||||||
|
"compact.input_tokens",
|
||||||
|
stats.usage.input_total_tokens,
|
||||||
|
&dimensions,
|
||||||
|
&correlation_id,
|
||||||
|
),
|
||||||
|
metric_with_context(
|
||||||
|
"compact.output_tokens",
|
||||||
|
stats.usage.output_tokens,
|
||||||
|
&dimensions,
|
||||||
|
&correlation_id,
|
||||||
|
),
|
||||||
|
metric_with_context(
|
||||||
|
"compact.cache_read_tokens",
|
||||||
|
stats.usage.cache_read_tokens,
|
||||||
|
&dimensions,
|
||||||
|
&correlation_id,
|
||||||
|
),
|
||||||
|
metric_with_context(
|
||||||
|
"compact.cache_creation_tokens",
|
||||||
|
stats.usage.cache_write_tokens,
|
||||||
|
&dimensions,
|
||||||
|
&correlation_id,
|
||||||
|
),
|
||||||
|
metric_with_context(
|
||||||
|
"compact.requests",
|
||||||
|
stats.requests,
|
||||||
|
&dimensions,
|
||||||
|
&correlation_id,
|
||||||
|
),
|
||||||
|
metric_with_context("compact.turns", stats.turns, &dimensions, &correlation_id),
|
||||||
|
metric_with_context(
|
||||||
|
"compact.tool_calls",
|
||||||
|
stats.tool_calls,
|
||||||
|
&dimensions,
|
||||||
|
&correlation_id,
|
||||||
|
),
|
||||||
|
metric_with_context(
|
||||||
|
"compact.duration_ms",
|
||||||
|
elapsed.as_millis().min(u128::from(MAX_SAFE_INTEGER)) as u64,
|
||||||
|
&dimensions,
|
||||||
|
&correlation_id,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for metric in &mut metrics {
|
||||||
|
metric
|
||||||
|
.dimensions
|
||||||
|
.insert("result_segment_id".into(), result_segment_id.to_string());
|
||||||
|
}
|
||||||
|
// Provider UsageEvent currently carries tokens but no price or cost. Keep
|
||||||
|
// the field explicit and valueless rather than fabricating a zero cost.
|
||||||
|
metrics.push(
|
||||||
|
self.metric("compact.cost_usd")
|
||||||
|
.with_dimension("status", "unavailable")
|
||||||
|
.with_dimension("reason", "provider_usage_unpriced")
|
||||||
|
.with_dimension("result_segment_id", result_segment_id.to_string()),
|
||||||
|
);
|
||||||
|
metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn failure_metrics(
|
||||||
|
&self,
|
||||||
|
observed_segment_id: SegmentId,
|
||||||
|
elapsed: Duration,
|
||||||
|
category: CompactFailureCategory,
|
||||||
|
) -> [Metric; 2] {
|
||||||
|
let outcome = if category == CompactFailureCategory::Cancelled {
|
||||||
|
"cancelled"
|
||||||
|
} else {
|
||||||
|
"failed"
|
||||||
|
};
|
||||||
|
let outcome_metric = self
|
||||||
|
.metric("compact.finish")
|
||||||
|
.with_value(1.0)
|
||||||
|
.with_dimension("outcome", outcome)
|
||||||
|
.with_dimension("failure_category", category.as_str())
|
||||||
|
.with_dimension("observed_segment_id", observed_segment_id.to_string());
|
||||||
|
let duration_metric = self
|
||||||
|
.metric("compact.duration_ms")
|
||||||
|
.with_value(elapsed.as_millis().min(u128::from(MAX_SAFE_INTEGER)) as f64)
|
||||||
|
.with_dimension("outcome", outcome)
|
||||||
|
.with_dimension("observed_segment_id", observed_segment_id.to_string());
|
||||||
|
[outcome_metric, duration_metric]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metric(&self, name: &'static str) -> Metric {
|
||||||
|
let mut metric = Metric::now(name).with_correlation_id(&self.correlation_id);
|
||||||
|
metric.dimensions = self.base_dimensions();
|
||||||
|
metric
|
||||||
|
}
|
||||||
|
|
||||||
|
fn base_dimensions(&self) -> BTreeMap<String, String> {
|
||||||
|
BTreeMap::from([
|
||||||
|
("session_id".into(), self.session_id.to_string()),
|
||||||
|
(
|
||||||
|
"source_segment_id".into(),
|
||||||
|
self.source_segment_id.to_string(),
|
||||||
|
),
|
||||||
|
("mode".into(), self.mode.as_str().into()),
|
||||||
|
("trigger".into(), self.threshold_policy.as_str().into()),
|
||||||
|
(
|
||||||
|
"threshold_policy".into(),
|
||||||
|
self.threshold_policy.as_str().into(),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct CompactSuccessStats {
|
||||||
|
pub(crate) retained_items: u64,
|
||||||
|
pub(crate) summarized_items: u64,
|
||||||
|
pub(crate) retained_tokens: u64,
|
||||||
|
pub(crate) retained_tokens_source: EstimateSource,
|
||||||
|
pub(crate) overview_tokens: u64,
|
||||||
|
pub(crate) summary_tokens: u64,
|
||||||
|
pub(crate) auto_read_tokens: u64,
|
||||||
|
pub(crate) result_context_tokens: u64,
|
||||||
|
pub(crate) result_context_source: EstimateSource,
|
||||||
|
pub(crate) usage: UsageSnapshot,
|
||||||
|
pub(crate) requests: u64,
|
||||||
|
pub(crate) turns: u64,
|
||||||
|
pub(crate) tool_calls: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn metric_with_context(
|
||||||
|
name: &'static str,
|
||||||
|
value: u64,
|
||||||
|
dimensions: &BTreeMap<String, String>,
|
||||||
|
correlation_id: &str,
|
||||||
|
) -> Metric {
|
||||||
|
let mut metric = Metric::now(name)
|
||||||
|
.with_value(safe_metric_number(value))
|
||||||
|
.with_correlation_id(correlation_id);
|
||||||
|
metric.dimensions = dimensions.clone();
|
||||||
|
metric
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new_compact_metric_correlation_id(lifecycle_id: &str) -> String {
|
||||||
|
loop {
|
||||||
|
let correlation_id = uuid::Uuid::now_v7().to_string();
|
||||||
|
if correlation_id != lifecycle_id {
|
||||||
|
return correlation_id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn correlated_post_request_metric(
|
||||||
|
kind: PostRequestMetric,
|
||||||
|
correlation_id: &str,
|
||||||
|
record: &UsageRecord,
|
||||||
|
) -> Metric {
|
||||||
|
let value = match kind {
|
||||||
|
PostRequestMetric::Prune => record.cache_read_tokens,
|
||||||
|
PostRequestMetric::Compaction => record.input_total_tokens,
|
||||||
|
};
|
||||||
|
Metric::now(kind.name())
|
||||||
|
.with_correlation_id(correlation_id)
|
||||||
|
.with_value(safe_metric_number(value))
|
||||||
|
.with_dimension("history_len", record.history_len.to_string())
|
||||||
|
.with_dimension("input_total_tokens", record.input_total_tokens.to_string())
|
||||||
|
.with_dimension("cache_read_tokens", record.cache_read_tokens.to_string())
|
||||||
|
.with_dimension("cache_write_tokens", record.cache_write_tokens.to_string())
|
||||||
|
.with_dimension("output_tokens", record.output_tokens.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn safe_metric_number(value: u64) -> f64 {
|
||||||
|
value.min(MAX_SAFE_INTEGER) as f64
|
||||||
|
}
|
||||||
|
|
||||||
|
fn estimate_source(source: EstimateSource) -> &'static str {
|
||||||
|
match source {
|
||||||
|
EstimateSource::Measured => "provider",
|
||||||
|
EstimateSource::Interpolated | EstimateSource::Extrapolated | EstimateSource::NoData => {
|
||||||
|
"fallback"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compact_metrics_use_fixed_bounded_labels_and_safe_numbers() {
|
||||||
|
let attempt = CompactAttempt::new(
|
||||||
|
uuid::Uuid::now_v7().to_string(),
|
||||||
|
uuid::Uuid::now_v7(),
|
||||||
|
uuid::Uuid::now_v7(),
|
||||||
|
CompactMode::Automatic,
|
||||||
|
CompactThresholdPolicy::RequestThreshold,
|
||||||
|
u64::MAX,
|
||||||
|
EstimateSource::Measured,
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
let start = attempt.start_metric();
|
||||||
|
assert_eq!(start.name, "compact.start");
|
||||||
|
assert_eq!(start.value, Some(MAX_SAFE_INTEGER as f64));
|
||||||
|
assert_eq!(start.dimensions["mode"], "automatic");
|
||||||
|
assert_eq!(start.dimensions["trigger"], "request_threshold");
|
||||||
|
assert_eq!(start.dimensions["threshold_policy"], "request_threshold");
|
||||||
|
assert_eq!(start.dimensions["occupancy_source"], "provider");
|
||||||
|
assert!(start.correlation_id.is_some());
|
||||||
|
assert!(start.dimensions.keys().all(|key| key.len() <= 32));
|
||||||
|
assert!(start.dimensions.values().all(|value| value.len() <= 64));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn occupancy_sources_match_the_public_provider_fallback_schema() {
|
||||||
|
assert_eq!(estimate_source(EstimateSource::Measured), "provider");
|
||||||
|
assert_eq!(estimate_source(EstimateSource::Interpolated), "fallback");
|
||||||
|
assert_eq!(estimate_source(EstimateSource::Extrapolated), "fallback");
|
||||||
|
assert_eq!(estimate_source(EstimateSource::NoData), "fallback");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn metric_correlation_id_is_distinct_from_lifecycle_identity() {
|
||||||
|
let lifecycle_id = uuid::Uuid::now_v7().to_string();
|
||||||
|
let correlation_id = new_compact_metric_correlation_id(&lifecycle_id);
|
||||||
|
assert_ne!(correlation_id, lifecycle_id);
|
||||||
|
assert!(uuid::Uuid::parse_str(&correlation_id).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn post_request_metric_saturates_values_above_json_safe_integer() {
|
||||||
|
let record = UsageRecord {
|
||||||
|
history_len: 1,
|
||||||
|
input_total_tokens: u64::MAX,
|
||||||
|
cache_read_tokens: 0,
|
||||||
|
cache_write_tokens: 0,
|
||||||
|
output_tokens: 1,
|
||||||
|
};
|
||||||
|
let metric = correlated_post_request_metric(
|
||||||
|
PostRequestMetric::Compaction,
|
||||||
|
"018f6f8a-9822-7b11-8b35-706f30313700",
|
||||||
|
&record,
|
||||||
|
);
|
||||||
|
assert_eq!(metric.name, "compact.post_request");
|
||||||
|
assert_eq!(metric.value, Some(MAX_SAFE_INTEGER as f64));
|
||||||
|
assert_eq!(
|
||||||
|
metric.dimensions["input_total_tokens"],
|
||||||
|
u64::MAX.to_string()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failure_metrics_never_include_error_text() {
|
||||||
|
let attempt = CompactAttempt::new(
|
||||||
|
uuid::Uuid::now_v7().to_string(),
|
||||||
|
uuid::Uuid::now_v7(),
|
||||||
|
uuid::Uuid::now_v7(),
|
||||||
|
CompactMode::Manual,
|
||||||
|
CompactThresholdPolicy::Manual,
|
||||||
|
1,
|
||||||
|
EstimateSource::NoData,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
let [metric, duration] = attempt.failure_metrics(
|
||||||
|
uuid::Uuid::now_v7(),
|
||||||
|
Duration::from_millis(7),
|
||||||
|
CompactFailureCategory::InternalWorker,
|
||||||
|
);
|
||||||
|
let encoded = serde_json::to_string(&metric).unwrap();
|
||||||
|
assert_eq!(metric.value, Some(1.0));
|
||||||
|
assert_eq!(metric.dimensions["outcome"], "failed");
|
||||||
|
assert_eq!(duration.name, "compact.duration_ms");
|
||||||
|
assert_eq!(duration.value, Some(7.0));
|
||||||
|
assert!(encoded.contains("internal_worker"));
|
||||||
|
assert!(!encoded.contains("error"));
|
||||||
|
assert!(!encoded.contains("path"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,14 +19,41 @@ use std::sync::Mutex;
|
|||||||
use agen::UsageRecord;
|
use agen::UsageRecord;
|
||||||
use agen::timeline::event::UsageEvent;
|
use agen::timeline::event::UsageEvent;
|
||||||
|
|
||||||
/// One drained measurement: the underlying `UsageRecord` plus an optional
|
/// The metric emitted after the next measured provider request.
|
||||||
/// `correlation_id` stamped by the prune projection (or any other future
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
/// upstream observer) so that downstream metrics emitted alongside this
|
pub(crate) enum PostRequestMetric {
|
||||||
/// record can be joined to it after the fact.
|
Prune,
|
||||||
|
Compaction,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PostRequestMetric {
|
||||||
|
pub(crate) fn name(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Prune => "prune.post_request",
|
||||||
|
Self::Compaction => "compact.post_request",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) struct PostRequestLink {
|
||||||
|
pub(crate) correlation_id: String,
|
||||||
|
pub(crate) metric: PostRequestMetric,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One drained measurement and its causal metric links.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct RecordedUsage {
|
pub(crate) struct RecordedUsage {
|
||||||
pub(crate) record: UsageRecord,
|
pub(crate) record: UsageRecord,
|
||||||
pub(crate) correlation_id: Option<String>,
|
pub(crate) post_requests: Vec<PostRequestLink>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
|
pub(crate) struct UsageSnapshot {
|
||||||
|
pub(crate) input_total_tokens: u64,
|
||||||
|
pub(crate) cache_read_tokens: u64,
|
||||||
|
pub(crate) cache_write_tokens: u64,
|
||||||
|
pub(crate) output_tokens: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared between the pre-request hook, the `on_usage` callback, and Worker.
|
/// Shared between the pre-request hook, the `on_usage` callback, and Worker.
|
||||||
@@ -34,11 +61,8 @@ pub(crate) struct UsageTracker {
|
|||||||
/// `history.len()` captured at the most recent `pre_llm_request`.
|
/// `history.len()` captured at the most recent `pre_llm_request`.
|
||||||
/// Cleared when paired with an incoming `on_usage` event.
|
/// Cleared when paired with an incoming `on_usage` event.
|
||||||
pending_history_len: Mutex<Option<usize>>,
|
pending_history_len: Mutex<Option<usize>>,
|
||||||
/// Optional `correlation_id` set by an upstream observer (currently
|
/// Optional causal link consumed by the next measured request.
|
||||||
/// the prune projection on `Fired`). Paired into the next
|
pending_correlations: Mutex<Vec<PostRequestLink>>,
|
||||||
/// `RecordedUsage` and cleared. Skips that don't fire leave this
|
|
||||||
/// `None`, so the resulting record carries no correlation.
|
|
||||||
pending_correlation_id: Mutex<Option<String>>,
|
|
||||||
/// Records accumulated during the current run; drained by Worker.
|
/// Records accumulated during the current run; drained by Worker.
|
||||||
pending_records: Mutex<Vec<RecordedUsage>>,
|
pending_records: Mutex<Vec<RecordedUsage>>,
|
||||||
}
|
}
|
||||||
@@ -47,7 +71,7 @@ impl UsageTracker {
|
|||||||
pub(crate) fn new() -> Self {
|
pub(crate) fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
pending_history_len: Mutex::new(None),
|
pending_history_len: Mutex::new(None),
|
||||||
pending_correlation_id: Mutex::new(None),
|
pending_correlations: Mutex::new(Vec::new()),
|
||||||
pending_records: Mutex::new(Vec::new()),
|
pending_records: Mutex::new(Vec::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -57,16 +81,23 @@ impl UsageTracker {
|
|||||||
*self.pending_history_len.lock().unwrap() = Some(history_len);
|
*self.pending_history_len.lock().unwrap() = Some(history_len);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stash a `correlation_id` to be paired into the next `RecordedUsage`.
|
/// Pair a prune event with the next provider request.
|
||||||
/// Currently invoked by the prune observer on `Fired` so that the
|
|
||||||
/// `prune.fire` metric and the `prune.post_request` metric (emitted
|
|
||||||
/// alongside the resulting `LlmUsage`) carry the same join key.
|
|
||||||
///
|
|
||||||
/// Overwrites any previous unconsumed value — by construction the
|
|
||||||
/// observer fires at most once per outgoing LLM request, immediately
|
|
||||||
/// before the pre-request hook captures `history_len`.
|
|
||||||
pub(crate) fn note_correlation_id(&self, id: String) {
|
pub(crate) fn note_correlation_id(&self, id: String) {
|
||||||
*self.pending_correlation_id.lock().unwrap() = Some(id);
|
self.note_post_request(id, PostRequestMetric::Prune);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pair a completed compaction with the next normal provider request.
|
||||||
|
pub(crate) fn note_compaction_correlation_id(&self, id: String) {
|
||||||
|
self.note_post_request(id, PostRequestMetric::Compaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_post_request(&self, id: String, metric: PostRequestMetric) {
|
||||||
|
let mut pending = self.pending_correlations.lock().unwrap();
|
||||||
|
pending.retain(|link| link.metric != metric);
|
||||||
|
pending.push(PostRequestLink {
|
||||||
|
correlation_id: id,
|
||||||
|
metric,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called from the `on_usage` callback with the aggregated final
|
/// Called from the `on_usage` callback with the aggregated final
|
||||||
@@ -79,7 +110,7 @@ impl UsageTracker {
|
|||||||
Some(n) => n,
|
Some(n) => n,
|
||||||
None => return,
|
None => return,
|
||||||
};
|
};
|
||||||
let correlation_id = self.pending_correlation_id.lock().unwrap().take();
|
let post_requests = std::mem::take(&mut *self.pending_correlations.lock().unwrap());
|
||||||
// UsageEvent.input_tokens は scheme 層で「占有量(プロンプト全長)」に
|
// UsageEvent.input_tokens は scheme 層で「占有量(プロンプト全長)」に
|
||||||
// 正規化済みである前提(Anthropic は cache_read + cache_creation を
|
// 正規化済みである前提(Anthropic は cache_read + cache_creation を
|
||||||
// 加算して emit する)。
|
// 加算して emit する)。
|
||||||
@@ -95,7 +126,7 @@ impl UsageTracker {
|
|||||||
cache_write_tokens: cache_write,
|
cache_write_tokens: cache_write,
|
||||||
output_tokens: output,
|
output_tokens: output,
|
||||||
},
|
},
|
||||||
correlation_id,
|
post_requests,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +176,7 @@ mod tests {
|
|||||||
assert_eq!(records[0].record.cache_read_tokens, 800);
|
assert_eq!(records[0].record.cache_read_tokens, 800);
|
||||||
assert_eq!(records[0].record.cache_write_tokens, 100);
|
assert_eq!(records[0].record.cache_write_tokens, 100);
|
||||||
assert_eq!(records[0].record.output_tokens, 42);
|
assert_eq!(records[0].record.output_tokens, 42);
|
||||||
assert!(records[0].correlation_id.is_none());
|
assert!(records[0].post_requests.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -192,6 +223,24 @@ mod tests {
|
|||||||
assert_eq!(records[1].record.cache_read_tokens, 50);
|
assert_eq!(records[1].record.cache_read_tokens, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prune_and_compaction_links_share_the_next_request() {
|
||||||
|
let tracker = UsageTracker::new();
|
||||||
|
tracker.note_compaction_correlation_id("compact-id".into());
|
||||||
|
tracker.note_correlation_id("prune-id".into());
|
||||||
|
tracker.note_request(5);
|
||||||
|
tracker.record_usage(&make_event(100, 10, 2, 20));
|
||||||
|
|
||||||
|
let records = tracker.drain();
|
||||||
|
assert_eq!(records[0].post_requests.len(), 2);
|
||||||
|
assert!(records[0].post_requests.iter().any(|link| {
|
||||||
|
link.correlation_id == "compact-id" && link.metric == PostRequestMetric::Compaction
|
||||||
|
}));
|
||||||
|
assert!(records[0].post_requests.iter().any(|link| {
|
||||||
|
link.correlation_id == "prune-id" && link.metric == PostRequestMetric::Prune
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn correlation_id_pairs_with_next_record_only() {
|
fn correlation_id_pairs_with_next_record_only() {
|
||||||
let tracker = UsageTracker::new();
|
let tracker = UsageTracker::new();
|
||||||
@@ -205,7 +254,9 @@ mod tests {
|
|||||||
|
|
||||||
let records = tracker.drain();
|
let records = tracker.drain();
|
||||||
assert_eq!(records.len(), 2);
|
assert_eq!(records.len(), 2);
|
||||||
assert_eq!(records[0].correlation_id.as_deref(), Some("abc"));
|
assert_eq!(records[0].post_requests.len(), 1);
|
||||||
assert!(records[1].correlation_id.is_none());
|
assert_eq!(records[0].post_requests[0].correlation_id, "abc");
|
||||||
|
assert_eq!(records[0].post_requests[0].metric, PostRequestMetric::Prune);
|
||||||
|
assert!(records[1].post_requests.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-101
@@ -190,12 +190,6 @@ fn command_admission_disposition(
|
|||||||
Err(WorkerCommandDisposition::StaleCommandId)
|
Err(WorkerCommandDisposition::StaleCommandId)
|
||||||
}
|
}
|
||||||
WorkerCommandAdmission::Conflict => Err(WorkerCommandDisposition::Conflict),
|
WorkerCommandAdmission::Conflict => Err(WorkerCommandDisposition::Conflict),
|
||||||
WorkerCommandAdmission::ExecutionGenerationMismatch => {
|
|
||||||
Err(WorkerCommandDisposition::StaleExecutionGeneration)
|
|
||||||
}
|
|
||||||
WorkerCommandAdmission::StateRevisionMismatch => {
|
|
||||||
Err(WorkerCommandDisposition::StaleWorkerStateRevision)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,14 +198,14 @@ fn validate_command(
|
|||||||
kind: WorkerCommandKind,
|
kind: WorkerCommandKind,
|
||||||
shared_state: &WorkerSharedState,
|
shared_state: &WorkerSharedState,
|
||||||
) -> Result<(), WorkerCommandDisposition> {
|
) -> Result<(), WorkerCommandDisposition> {
|
||||||
command_admission_disposition(shared_state.admit_command(envelope, kind, true))
|
command_admission_disposition(shared_state.admit_command(envelope, kind))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_shutdown_command(
|
fn validate_shutdown_command(
|
||||||
envelope: WorkerCommandEnvelope,
|
envelope: WorkerCommandEnvelope,
|
||||||
shared_state: &WorkerSharedState,
|
shared_state: &WorkerSharedState,
|
||||||
) -> Result<(), WorkerCommandDisposition> {
|
) -> Result<(), WorkerCommandDisposition> {
|
||||||
match shared_state.admit_command(envelope, WorkerCommandKind::Shutdown, false) {
|
match shared_state.admit_command(envelope, WorkerCommandKind::Shutdown) {
|
||||||
WorkerCommandAdmission::Accepted | WorkerCommandAdmission::Retry => Ok(()),
|
WorkerCommandAdmission::Accepted | WorkerCommandAdmission::Retry => Ok(()),
|
||||||
admission => command_admission_disposition(admission),
|
admission => command_admission_disposition(admission),
|
||||||
}
|
}
|
||||||
@@ -486,7 +480,7 @@ impl WorkerController {
|
|||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
Self::spawn_inner(
|
let (handle, shutdown, _task) = Self::spawn_inner(
|
||||||
worker,
|
worker,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
bash_output_dir,
|
bash_output_dir,
|
||||||
@@ -494,7 +488,8 @@ impl WorkerController {
|
|||||||
None,
|
None,
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
|
Ok((handle, shutdown))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a direct Worker while letting an in-process host select the
|
/// Spawn a direct Worker while letting an in-process host select the
|
||||||
@@ -505,6 +500,22 @@ impl WorkerController {
|
|||||||
bash_output_dir: &Path,
|
bash_output_dir: &Path,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
|
where
|
||||||
|
C: LlmClient + Clone + 'static,
|
||||||
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let (handle, shutdown, _task) =
|
||||||
|
Self::spawn_with_transport_owned(worker, runtime_base, bash_output_dir, transport)
|
||||||
|
.await?;
|
||||||
|
Ok((handle, shutdown))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn spawn_with_transport_owned<C, St>(
|
||||||
|
worker: Worker<C, St>,
|
||||||
|
runtime_base: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
|
transport: WorkerControllerTransport,
|
||||||
|
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
@@ -535,7 +546,7 @@ impl WorkerController {
|
|||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
{
|
{
|
||||||
Self::spawn_inner(
|
let (handle, shutdown, _task) = Self::spawn_inner(
|
||||||
worker,
|
worker,
|
||||||
runtime_base,
|
runtime_base,
|
||||||
bash_output_dir,
|
bash_output_dir,
|
||||||
@@ -543,7 +554,8 @@ impl WorkerController {
|
|||||||
None,
|
None,
|
||||||
WorkerControllerTransport::UnixSocket,
|
WorkerControllerTransport::UnixSocket,
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
|
Ok((handle, shutdown))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn into an exact persistent `runs/<generation>` directory.
|
/// Spawn into an exact persistent `runs/<generation>` directory.
|
||||||
@@ -573,6 +585,26 @@ impl WorkerController {
|
|||||||
bash_output_dir: &Path,
|
bash_output_dir: &Path,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
||||||
|
where
|
||||||
|
C: LlmClient + Clone + 'static,
|
||||||
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let (handle, shutdown, _task) = Self::spawn_runtime_managed_run_with_transport_owned(
|
||||||
|
worker,
|
||||||
|
run_dir,
|
||||||
|
bash_output_dir,
|
||||||
|
transport,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok((handle, shutdown))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn spawn_runtime_managed_run_with_transport_owned<C, St>(
|
||||||
|
worker: Worker<C, St>,
|
||||||
|
run_dir: &Path,
|
||||||
|
bash_output_dir: &Path,
|
||||||
|
transport: WorkerControllerTransport,
|
||||||
|
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
@@ -598,7 +630,7 @@ impl WorkerController {
|
|||||||
runtime_managed: bool,
|
runtime_managed: bool,
|
||||||
runtime_run: Option<&Path>,
|
runtime_run: Option<&Path>,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
@@ -629,7 +661,7 @@ impl WorkerController {
|
|||||||
runtime_managed: bool,
|
runtime_managed: bool,
|
||||||
runtime_run: Option<&Path>,
|
runtime_run: Option<&Path>,
|
||||||
transport: WorkerControllerTransport,
|
transport: WorkerControllerTransport,
|
||||||
) -> Result<(WorkerHandle, ShutdownReceiver), std::io::Error>
|
) -> Result<(WorkerHandle, ShutdownReceiver, tokio::task::JoinHandle<()>), std::io::Error>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
St: Store + WorkerMetadataStore + Clone + Send + Sync + 'static,
|
||||||
@@ -699,6 +731,7 @@ impl WorkerController {
|
|||||||
target: bash_output_dir.clone(),
|
target: bash_output_dir.clone(),
|
||||||
permission: manifest::Permission::Read,
|
permission: manifest::Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}])
|
}])
|
||||||
.map_err(std::io::Error::other)?;
|
.map_err(std::io::Error::other)?;
|
||||||
|
|
||||||
@@ -728,9 +761,9 @@ impl WorkerController {
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(session) = fs_for_view.as_ref() {
|
let command_observer = fs_for_view
|
||||||
wire_workdir_command_events(session, &in_flight);
|
.as_ref()
|
||||||
}
|
.and_then(|session| wire_workdir_command_events(session, &in_flight));
|
||||||
|
|
||||||
// Intake role Workers self-terminate only after a successful
|
// Intake role Workers self-terminate only after a successful
|
||||||
// TicketIntakeReady turn has fully settled back to Idle. The request
|
// TicketIntakeReady turn has fully settled back to Idle. The request
|
||||||
@@ -754,19 +787,11 @@ impl WorkerController {
|
|||||||
.await
|
.await
|
||||||
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
.map_err(|error| std::io::Error::other(error.to_string()))?;
|
||||||
let greeting = build_greeting(&worker);
|
let greeting = build_greeting(&worker);
|
||||||
let execution_generation = runtime_dir
|
let shared_state = Arc::new(WorkerSharedState::new(
|
||||||
.path()
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.and_then(|name| name.parse::<u64>().ok())
|
|
||||||
.filter(|generation| *generation > 0)
|
|
||||||
.unwrap_or(1);
|
|
||||||
let shared_state = Arc::new(WorkerSharedState::new_with_generation(
|
|
||||||
worker.manifest().worker.name.clone(),
|
worker.manifest().worker.name.clone(),
|
||||||
worker.segment_id(),
|
worker.segment_id(),
|
||||||
manifest_toml.clone(),
|
manifest_toml.clone(),
|
||||||
greeting,
|
greeting,
|
||||||
execution_generation,
|
|
||||||
));
|
));
|
||||||
if let Some(fs_for_view) = fs_for_view {
|
if let Some(fs_for_view) = fs_for_view {
|
||||||
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
|
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
|
||||||
@@ -804,7 +829,7 @@ impl WorkerController {
|
|||||||
let pause_tx = worker.engine_mut().pause_sender();
|
let pause_tx = worker.engine_mut().pause_sender();
|
||||||
let notify_buffer = worker.notify_buffer_handle();
|
let notify_buffer = worker.notify_buffer_handle();
|
||||||
|
|
||||||
tokio::spawn(controller_loop(
|
let controller_task = tokio::spawn(controller_loop(
|
||||||
worker,
|
worker,
|
||||||
method_rx,
|
method_rx,
|
||||||
working_event_tx,
|
working_event_tx,
|
||||||
@@ -819,26 +844,27 @@ impl WorkerController {
|
|||||||
shutdown_tx,
|
shutdown_tx,
|
||||||
socket_server,
|
socket_server,
|
||||||
shutdown_after_idle,
|
shutdown_after_idle,
|
||||||
|
command_observer,
|
||||||
));
|
));
|
||||||
|
|
||||||
Ok((handle, shutdown_rx))
|
Ok((handle, shutdown_rx, controller_task))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn wire_workdir_command_events(
|
pub(crate) fn wire_workdir_command_events(
|
||||||
session: &Arc<dyn WorkdirSession>,
|
session: &Arc<dyn WorkdirSession>,
|
||||||
in_flight: &InFlightEvents,
|
in_flight: &InFlightEvents,
|
||||||
) {
|
) -> Option<tokio::task::JoinHandle<()>> {
|
||||||
in_flight.replace_command_snapshot(protocol_command_snapshots(session.as_ref()));
|
in_flight.replace_command_snapshot(protocol_command_snapshots(session.as_ref()));
|
||||||
let Some(mut events) = session.subscribe_command_events() else {
|
let Some(mut events) = session.subscribe_command_events() else {
|
||||||
return;
|
return None;
|
||||||
};
|
};
|
||||||
// Keep only a weak reference in the observer task. Holding the session
|
// Keep only a weak reference in the observer task. Holding the session
|
||||||
// strongly here would keep its broadcast sender alive forever and prevent
|
// strongly here would keep its broadcast sender alive forever and prevent
|
||||||
// the receiver from observing closure during Worker teardown.
|
// the receiver from observing closure during Worker teardown.
|
||||||
let session = Arc::downgrade(session);
|
let session = Arc::downgrade(session);
|
||||||
let in_flight = in_flight.clone();
|
let in_flight = in_flight.clone();
|
||||||
tokio::spawn(async move {
|
Some(tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
match events.recv().await {
|
match events.recv().await {
|
||||||
Ok(event) => in_flight.publish_command_event(protocol_command_event(event)),
|
Ok(event) => in_flight.publish_command_event(protocol_command_event(event)),
|
||||||
@@ -852,7 +878,7 @@ pub(crate) fn wire_workdir_command_events(
|
|||||||
Err(broadcast::error::RecvError::Closed) => break,
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn protocol_command_snapshots(session: &dyn WorkdirSession) -> Vec<ProtocolCommandSnapshot> {
|
fn protocol_command_snapshots(session: &dyn WorkdirSession) -> Vec<ProtocolCommandSnapshot> {
|
||||||
@@ -1385,12 +1411,6 @@ where
|
|||||||
feature_registry
|
feature_registry
|
||||||
.add_module(crate::feature::builtin::orchestration::orchestration_feature());
|
.add_module(crate::feature::builtin::orchestration::orchestration_feature());
|
||||||
}
|
}
|
||||||
for module in crate::feature::plugin::plugin_tool_features_if_enabled(
|
|
||||||
feature_config.plugins.enabled,
|
|
||||||
&worker.manifest().plugins,
|
|
||||||
) {
|
|
||||||
feature_registry = feature_registry.with_module(module);
|
|
||||||
}
|
|
||||||
if let Some(workspace_root) = local_workspace_root.as_ref() {
|
if let Some(workspace_root) = local_workspace_root.as_ref() {
|
||||||
if let Some(module) =
|
if let Some(module) =
|
||||||
crate::feature::mcp::discover_stdio_tool_feature(&mcp_config, workspace_root).await
|
crate::feature::mcp::discover_stdio_tool_feature(&mcp_config, workspace_root).await
|
||||||
@@ -1512,6 +1532,7 @@ async fn controller_loop<C, St>(
|
|||||||
shutdown_tx: oneshot::Sender<()>,
|
shutdown_tx: oneshot::Sender<()>,
|
||||||
socket_server: Option<SocketServer>,
|
socket_server: Option<SocketServer>,
|
||||||
shutdown_after_idle: ShutdownAfterIdleRequest,
|
shutdown_after_idle: ShutdownAfterIdleRequest,
|
||||||
|
mut command_observer: Option<tokio::task::JoinHandle<()>>,
|
||||||
) where
|
) where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
St: Store + WorkerMetadataStore + Clone + 'static,
|
St: Store + WorkerMetadataStore + Clone + 'static,
|
||||||
@@ -2348,28 +2369,53 @@ async fn controller_loop<C, St>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
drop(_socket_server);
|
// Close method admission before any fallible child/session cleanup. Existing
|
||||||
if let Err(error) = runtime_dir.close_socket().await {
|
// senders then fail instead of accepting work that this execution can no
|
||||||
tracing::warn!(%error, "Worker runtime socket cleanup failed");
|
// longer process.
|
||||||
|
drop(method_rx);
|
||||||
|
let had_socket_server = _socket_server.is_some();
|
||||||
|
if let Some(socket_server) = _socket_server {
|
||||||
|
socket_server.shutdown().await;
|
||||||
|
}
|
||||||
|
while had_socket_server {
|
||||||
|
match runtime_dir.close_socket().await {
|
||||||
|
Ok(()) => break,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "Worker runtime socket cleanup failed; retrying");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Feature callbacks and tasks share the Worker scope. Stop them before
|
// Feature callbacks and tasks share the Worker scope. Stop them before
|
||||||
// Memory/Workdir teardown so they cannot observe a partially closed Worker.
|
// Memory/Workdir teardown so they cannot observe a partially closed Worker.
|
||||||
worker.stop_feature_runtime("controller shutdown").await;
|
worker.stop_feature_runtime("controller shutdown").await;
|
||||||
|
|
||||||
let child_cleanup_succeeded = match spawned_registry.shutdown_internal().await {
|
loop {
|
||||||
Ok(()) => true,
|
match spawned_registry.shutdown_internal().await {
|
||||||
Err(error) => {
|
Ok(()) => break,
|
||||||
tracing::warn!(%error, "Internal SubWorker cleanup failed before Workdir shutdown");
|
Err(error) => {
|
||||||
false
|
tracing::warn!(%error, "Internal SubWorker cleanup failed; retrying");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
if child_cleanup_succeeded
|
if let Some(session) = worker.workdir_session() {
|
||||||
&& let Some(session) = worker.workdir_session()
|
loop {
|
||||||
&& let Err(error) = session.close().await
|
match session.close().await {
|
||||||
{
|
Ok(()) => break,
|
||||||
tracing::warn!(%error, "Workdir session close failed");
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "Workdir session close failed; retrying");
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(observer) = command_observer.take() {
|
||||||
|
observer.abort();
|
||||||
|
let _ = observer.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Report upward that this Worker is stopping before the controller
|
// Report upward that this Worker is stopping before the controller
|
||||||
@@ -3376,7 +3422,7 @@ mod tests {
|
|||||||
.transition(WorkerState::Busy(WorkerBusyState::Run(
|
.transition(WorkerState::Busy(WorkerBusyState::Run(
|
||||||
WorkerRunState::Running,
|
WorkerRunState::Running,
|
||||||
)));
|
)));
|
||||||
let command = WorkerCommandEnvelope::for_snapshot(1, &env.shared_state.snapshot());
|
let command = WorkerCommandEnvelope::new(1);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
method_tx
|
method_tx
|
||||||
@@ -3659,7 +3705,7 @@ mod tests {
|
|||||||
.transition(WorkerState::Busy(WorkerBusyState::Run(
|
.transition(WorkerState::Busy(WorkerBusyState::Run(
|
||||||
WorkerRunState::Running,
|
WorkerRunState::Running,
|
||||||
)));
|
)));
|
||||||
let command = WorkerCommandEnvelope::for_snapshot(1, &env.shared_state.snapshot());
|
let command = WorkerCommandEnvelope::new(1);
|
||||||
env._method_tx
|
env._method_tx
|
||||||
.send(Method::Compact { command })
|
.send(Method::Compact { command })
|
||||||
.await
|
.await
|
||||||
@@ -3710,8 +3756,8 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn command_admission_rejects_stale_generation_revision_and_order() {
|
fn command_admission_rejects_stale_ids_and_reuse_conflicts() {
|
||||||
let shared = WorkerSharedState::new_with_generation(
|
let shared = WorkerSharedState::new(
|
||||||
"worker".into(),
|
"worker".into(),
|
||||||
session_store::new_segment_id(),
|
session_store::new_segment_id(),
|
||||||
String::new(),
|
String::new(),
|
||||||
@@ -3725,39 +3771,10 @@ mod tests {
|
|||||||
context_window: 1,
|
context_window: 1,
|
||||||
context_tokens: 0,
|
context_tokens: 0,
|
||||||
},
|
},
|
||||||
9,
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
validate_command(
|
|
||||||
WorkerCommandEnvelope {
|
|
||||||
command_id: 1,
|
|
||||||
expected_execution_generation: 8,
|
|
||||||
expected_worker_state_revision: 0,
|
|
||||||
},
|
|
||||||
WorkerCommandKind::Pause,
|
|
||||||
&shared,
|
|
||||||
),
|
|
||||||
Err(WorkerCommandDisposition::StaleExecutionGeneration)
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
validate_command(
|
|
||||||
WorkerCommandEnvelope {
|
|
||||||
command_id: 2,
|
|
||||||
expected_execution_generation: 9,
|
|
||||||
expected_worker_state_revision: 1,
|
|
||||||
},
|
|
||||||
WorkerCommandKind::Pause,
|
|
||||||
&shared,
|
|
||||||
),
|
|
||||||
Err(WorkerCommandDisposition::StaleWorkerStateRevision)
|
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
validate_command(
|
validate_command(
|
||||||
WorkerCommandEnvelope {
|
WorkerCommandEnvelope { command_id: 1 },
|
||||||
command_id: 1,
|
|
||||||
expected_execution_generation: 9,
|
|
||||||
expected_worker_state_revision: 0,
|
|
||||||
},
|
|
||||||
WorkerCommandKind::Pause,
|
WorkerCommandKind::Pause,
|
||||||
&shared,
|
&shared,
|
||||||
)
|
)
|
||||||
@@ -3765,11 +3782,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
validate_command(
|
validate_command(
|
||||||
WorkerCommandEnvelope {
|
WorkerCommandEnvelope { command_id: 1 },
|
||||||
command_id: 1,
|
|
||||||
expected_execution_generation: 9,
|
|
||||||
expected_worker_state_revision: 0,
|
|
||||||
},
|
|
||||||
WorkerCommandKind::Pause,
|
WorkerCommandKind::Pause,
|
||||||
&shared,
|
&shared,
|
||||||
),
|
),
|
||||||
@@ -3777,11 +3790,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
validate_command(
|
validate_command(
|
||||||
WorkerCommandEnvelope {
|
WorkerCommandEnvelope { command_id: 1 },
|
||||||
command_id: 1,
|
|
||||||
expected_execution_generation: 9,
|
|
||||||
expected_worker_state_revision: 0,
|
|
||||||
},
|
|
||||||
WorkerCommandKind::Cancel,
|
WorkerCommandKind::Cancel,
|
||||||
&shared,
|
&shared,
|
||||||
),
|
),
|
||||||
@@ -3789,11 +3798,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
validate_command(
|
validate_command(
|
||||||
WorkerCommandEnvelope {
|
WorkerCommandEnvelope { command_id: 2 },
|
||||||
command_id: 2,
|
|
||||||
expected_execution_generation: 9,
|
|
||||||
expected_worker_state_revision: 1,
|
|
||||||
},
|
|
||||||
WorkerCommandKind::Pause,
|
WorkerCommandKind::Pause,
|
||||||
&shared,
|
&shared,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -743,6 +743,7 @@ fn comm_info_from_spawned_child(child: &session_store::WorkerSpawnedChild) -> Co
|
|||||||
target: rule.target.clone(),
|
target: rule.target.clone(),
|
||||||
permission,
|
permission,
|
||||||
recursive: rule.recursive,
|
recursive: rule.recursive,
|
||||||
|
symlink_policy: rule.symlink_policy,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1324,6 +1325,7 @@ mod tests {
|
|||||||
target: root.path().to_path_buf(),
|
target: root.path().to_path_buf(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
active_child_segment,
|
active_child_segment,
|
||||||
)
|
)
|
||||||
@@ -1795,6 +1797,7 @@ mod tests {
|
|||||||
target: PathBuf::from("/tmp"),
|
target: PathBuf::from("/tmp"),
|
||||||
permission: "read".into(),
|
permission: "read".into(),
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
callback_address: PathBuf::from("/tmp/parent.sock"),
|
callback_address: PathBuf::from("/tmp/parent.sock"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,6 +286,7 @@ fn read_rule(target: PathBuf) -> ScopeRule {
|
|||||||
target,
|
target,
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +295,7 @@ fn write_rule(target: PathBuf) -> ScopeRule {
|
|||||||
target,
|
target,
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +308,7 @@ fn workspace_scope(
|
|||||||
target: workspace_root.to_path_buf(),
|
target: workspace_root.to_path_buf(),
|
||||||
permission,
|
permission,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
};
|
};
|
||||||
let deny = deny_write
|
let deny = deny_write
|
||||||
.iter()
|
.iter()
|
||||||
@@ -711,6 +714,7 @@ permission = "write"
|
|||||||
target: target.to_path_buf(),
|
target: target.to_path_buf(),
|
||||||
permission,
|
permission,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2211,7 +2211,6 @@ pub enum FeatureInstallError {
|
|||||||
pub mod background;
|
pub mod background;
|
||||||
pub mod builtin;
|
pub mod builtin;
|
||||||
pub mod mcp;
|
pub mod mcp;
|
||||||
pub mod plugin;
|
|
||||||
pub(crate) mod session;
|
pub(crate) mod session;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -2224,6 +2223,31 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn worker_feature_composition_has_no_dynamic_plugin_install_path() {
|
||||||
|
let feature_source = include_str!("feature.rs")
|
||||||
|
.split("#[cfg(test)]")
|
||||||
|
.next()
|
||||||
|
.unwrap();
|
||||||
|
let controller_source = include_str!("controller.rs")
|
||||||
|
.split("#[cfg(test)]")
|
||||||
|
.next()
|
||||||
|
.unwrap();
|
||||||
|
for forbidden in [
|
||||||
|
"pub mod plugin",
|
||||||
|
"plugin_tool_features_if_enabled",
|
||||||
|
"ResolvedPluginRecord",
|
||||||
|
"read_resolved_plugin_runtime_component",
|
||||||
|
"feature.plugins",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!feature_source.contains(forbidden) && !controller_source.contains(forbidden),
|
||||||
|
"dynamic Plugin install path returned through {forbidden}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(FeatureId::builtin("task").as_str(), "builtin:task");
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct DummyClient;
|
struct DummyClient;
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ use workdir::workspace::WorkspaceWorkdirSessionOperationRequest;
|
|||||||
use workdir::{
|
use workdir::{
|
||||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||||
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirSession,
|
ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError,
|
||||||
|
WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession,
|
||||||
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -283,6 +284,26 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
|||||||
WorkdirSessionCapabilities::ALL
|
WorkdirSessionCapabilities::ALL
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn authorize_scope_path(
|
||||||
|
&self,
|
||||||
|
request: WorkdirScopeAuthorizationRequest,
|
||||||
|
) -> Result<(), WorkdirError> {
|
||||||
|
match self.operate(WorkdirSessionOperation::AuthorizeScope(request))? {
|
||||||
|
WorkdirSessionOperationResult::AuthorizeScope => Ok(()),
|
||||||
|
_ => Err(Self::mismatch("authorize_scope")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn scope_rules_overlap(
|
||||||
|
&self,
|
||||||
|
request: WorkdirScopeOverlapRequest,
|
||||||
|
) -> Result<bool, WorkdirError> {
|
||||||
|
match self.operate(WorkdirSessionOperation::ScopeRulesOverlap(request))? {
|
||||||
|
WorkdirSessionOperationResult::ScopeRulesOverlap { overlaps } => Ok(overlaps),
|
||||||
|
_ => Err(Self::mismatch("scope_rules_overlap")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
match self.operate(WorkdirSessionOperation::Stat(request))? {
|
match self.operate(WorkdirSessionOperation::Stat(request))? {
|
||||||
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
||||||
@@ -863,9 +884,9 @@ mod tests {
|
|||||||
"repository_key": "main",
|
"repository_key": "main",
|
||||||
"creation_selector": "refs/heads/main",
|
"creation_selector": "refs/heads/main",
|
||||||
"creation_ref": "0123456789abcdef",
|
"creation_ref": "0123456789abcdef",
|
||||||
"materializer_kind": "local_git_worktree",
|
"materializer_kind": "runtime_git_clone",
|
||||||
"cleanup_target": {
|
"cleanup_target": {
|
||||||
"kind": "git_worktree",
|
"kind": "runtime_git_clone",
|
||||||
"working_directory_id": id,
|
"working_directory_id": id,
|
||||||
"repository_key": "main"
|
"repository_key": "main"
|
||||||
},
|
},
|
||||||
@@ -1243,10 +1264,8 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn scoped_broker_operations_carry_no_child_context() {
|
async fn scoped_broker_operations_carry_no_child_context() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
||||||
response(json!({
|
response(json!({ "operation": "authorize_scope" })),
|
||||||
"operation": "stat",
|
response(json!({ "operation": "authorize_scope" })),
|
||||||
"result": {"path": "visible.txt", "kind": "file", "size": 8}
|
|
||||||
})),
|
|
||||||
response(json!({
|
response(json!({
|
||||||
"operation": "stat",
|
"operation": "stat",
|
||||||
"result": {"path": "visible.txt", "kind": "file", "size": 8}
|
"result": {"path": "visible.txt", "kind": "file", "size": 8}
|
||||||
@@ -1261,6 +1280,7 @@ mod tests {
|
|||||||
target: workdir::WorkdirPath::new("").unwrap(),
|
target: workdir::WorkdirPath::new("").unwrap(),
|
||||||
permission: workdir::WorkdirToolScopePermission::Read,
|
permission: workdir::WorkdirToolScopePermission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
cwd: workdir::WorkdirPath::new("").unwrap(),
|
cwd: workdir::WorkdirPath::new("").unwrap(),
|
||||||
command: false,
|
command: false,
|
||||||
@@ -1275,7 +1295,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let requests = client.requests();
|
let requests = client.requests();
|
||||||
assert_eq!(requests.len(), 2);
|
assert_eq!(requests.len(), 3);
|
||||||
for request in requests {
|
for request in requests {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
request.path,
|
request.path,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -411,11 +411,13 @@ mod tests {
|
|||||||
target: dir.path().to_path_buf(),
|
target: dir.path().to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: vec![ScopeRule {
|
deny: vec![ScopeRule {
|
||||||
target: secret.clone(),
|
target: secret.clone(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
let scope = Scope::from_config(&cfg).unwrap();
|
let scope = Scope::from_config(&cfg).unwrap();
|
||||||
@@ -574,11 +576,13 @@ mod tests {
|
|||||||
target: dir.path().to_path_buf(),
|
target: dir.path().to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: vec![ScopeRule {
|
deny: vec![ScopeRule {
|
||||||
target: secret.clone(),
|
target: secret.clone(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
let scope = Scope::from_config(&cfg).unwrap();
|
let scope = Scope::from_config(&cfg).unwrap();
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ pub(crate) struct InFlightInner {
|
|||||||
next_block_id: u64,
|
next_block_id: u64,
|
||||||
blocks: Vec<TrackedBlock>,
|
blocks: Vec<TrackedBlock>,
|
||||||
commands: Vec<CommandSnapshot>,
|
commands: Vec<CommandSnapshot>,
|
||||||
|
compaction: Option<protocol::InFlightCompaction>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -53,6 +54,7 @@ impl InFlightEvents {
|
|||||||
next_block_id: 1,
|
next_block_id: 1,
|
||||||
blocks: Vec::new(),
|
blocks: Vec::new(),
|
||||||
commands: Vec::new(),
|
commands: Vec::new(),
|
||||||
|
compaction: None,
|
||||||
})),
|
})),
|
||||||
working_event_tx,
|
working_event_tx,
|
||||||
}
|
}
|
||||||
@@ -219,6 +221,15 @@ impl InFlightEvents {
|
|||||||
self.lock().commands = commands;
|
self.lock().commands = commands;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Atomically update reconnect state and publish the matching live progress event.
|
||||||
|
pub(crate) fn set_compaction(&self, compaction: Option<protocol::InFlightCompaction>) {
|
||||||
|
let mut inner = self.lock();
|
||||||
|
inner.compaction = compaction.clone();
|
||||||
|
let _ = self
|
||||||
|
.working_event_tx
|
||||||
|
.send(Event::CompactionProgress { compaction });
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn clear(&self) {
|
pub(crate) fn clear(&self) {
|
||||||
let mut inner = self.lock();
|
let mut inner = self.lock();
|
||||||
inner.clear();
|
inner.clear();
|
||||||
@@ -378,6 +389,7 @@ impl InFlightInner {
|
|||||||
.filter_map(TrackedBlock::to_snapshot_block)
|
.filter_map(TrackedBlock::to_snapshot_block)
|
||||||
.collect(),
|
.collect(),
|
||||||
commands: self.commands.clone(),
|
commands: self.commands.clone(),
|
||||||
|
compaction: self.compaction.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -740,6 +752,45 @@ mod tests {
|
|||||||
assert!(snapshot_from_guard(&guard).commands.is_empty());
|
assert!(snapshot_from_guard(&guard).commands.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compaction_progress_updates_snapshot_and_live_event_atomically() {
|
||||||
|
let (working_event_tx, _) = broadcast::channel(16);
|
||||||
|
let mut rx = working_event_tx.subscribe();
|
||||||
|
let in_flight = InFlightEvents::new(working_event_tx);
|
||||||
|
let progress = protocol::InFlightCompaction {
|
||||||
|
phase: protocol::CompactionPhase::Preparing,
|
||||||
|
started_at_ms: 100,
|
||||||
|
trigger: protocol::CompactionTrigger::Manual,
|
||||||
|
};
|
||||||
|
|
||||||
|
in_flight.set_compaction(Some(progress.clone()));
|
||||||
|
let guard = in_flight.snapshot_guard();
|
||||||
|
assert_eq!(
|
||||||
|
snapshot_from_guard(&guard).compaction,
|
||||||
|
Some(progress.clone())
|
||||||
|
);
|
||||||
|
assert!(!snapshot_from_guard(&guard).is_empty());
|
||||||
|
drop(guard);
|
||||||
|
assert!(matches!(
|
||||||
|
rx.try_recv().unwrap(),
|
||||||
|
Event::CompactionProgress { compaction: Some(item) } if item == progress
|
||||||
|
));
|
||||||
|
|
||||||
|
in_flight.clear();
|
||||||
|
let guard = in_flight.snapshot_guard();
|
||||||
|
assert!(snapshot_from_guard(&guard).compaction.is_some());
|
||||||
|
drop(guard);
|
||||||
|
|
||||||
|
in_flight.set_compaction(None);
|
||||||
|
let guard = in_flight.snapshot_guard();
|
||||||
|
assert!(snapshot_from_guard(&guard).compaction.is_none());
|
||||||
|
drop(guard);
|
||||||
|
assert!(matches!(
|
||||||
|
rx.try_recv().unwrap(),
|
||||||
|
Event::CompactionProgress { compaction: None }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn clear_discards_uncommitted_blocks_without_protocol_event() {
|
fn clear_discards_uncommitted_blocks_without_protocol_event() {
|
||||||
let (working_event_tx, _) = broadcast::channel(16);
|
let (working_event_tx, _) = broadcast::channel(16);
|
||||||
|
|||||||
@@ -127,7 +127,6 @@ where
|
|||||||
// parent manifest cannot accidentally grant its normal public tool surface
|
// parent manifest cannot accidentally grant its normal public tool surface
|
||||||
// or recursively schedule Feature-owned background work.
|
// or recursively schedule Feature-owned background work.
|
||||||
manifest.feature = Default::default();
|
manifest.feature = Default::default();
|
||||||
manifest.plugins = Default::default();
|
|
||||||
manifest.mcp = Default::default();
|
manifest.mcp = Default::default();
|
||||||
manifest.skills = None;
|
manifest.skills = None;
|
||||||
manifest.compaction = None;
|
manifest.compaction = None;
|
||||||
@@ -297,7 +296,6 @@ impl InternalWorkerSessionStatus {
|
|||||||
|
|
||||||
fn send_internal_worker_state(
|
fn send_internal_worker_state(
|
||||||
event_tx: &broadcast::Sender<Event>,
|
event_tx: &broadcast::Sender<Event>,
|
||||||
state_revision: &std::sync::atomic::AtomicU64,
|
|
||||||
status: InternalWorkerSessionStatus,
|
status: InternalWorkerSessionStatus,
|
||||||
) {
|
) {
|
||||||
let state = match status {
|
let state = match status {
|
||||||
@@ -314,13 +312,8 @@ fn send_internal_worker_state(
|
|||||||
protocol::WorkerBusyState::Run(protocol::WorkerRunState::Cancelling),
|
protocol::WorkerBusyState::Run(protocol::WorkerRunState::Cancelling),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
let revision = state_revision
|
|
||||||
.fetch_add(1, std::sync::atomic::Ordering::AcqRel)
|
|
||||||
.saturating_add(1);
|
|
||||||
let _ = event_tx.send(Event::WorkerState {
|
let _ = event_tx.send(Event::WorkerState {
|
||||||
snapshot: protocol::WorkerStateSnapshot {
|
snapshot: protocol::WorkerStateSnapshot {
|
||||||
execution_generation: 1,
|
|
||||||
revision,
|
|
||||||
last_command_id: 0,
|
last_command_id: 0,
|
||||||
state,
|
state,
|
||||||
},
|
},
|
||||||
@@ -383,7 +376,6 @@ pub(crate) struct InternalWorkerSessionSnapshot {
|
|||||||
pub(crate) struct InternalWorkerSessionHandle {
|
pub(crate) struct InternalWorkerSessionHandle {
|
||||||
command_tx: tokio::sync::mpsc::Sender<InternalWorkerSessionCommand>,
|
command_tx: tokio::sync::mpsc::Sender<InternalWorkerSessionCommand>,
|
||||||
status: Arc<std::sync::atomic::AtomicU8>,
|
status: Arc<std::sync::atomic::AtomicU8>,
|
||||||
state_revision: Arc<std::sync::atomic::AtomicU64>,
|
|
||||||
store: EphemeralSessionStore,
|
store: EphemeralSessionStore,
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
@@ -434,7 +426,7 @@ impl InternalWorkerSessionHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn emit_worker_state(&self, status: InternalWorkerSessionStatus) {
|
fn emit_worker_state(&self, status: InternalWorkerSessionStatus) {
|
||||||
send_internal_worker_state(&self.event_tx, &self.state_revision, status);
|
send_internal_worker_state(&self.event_tx, status);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn protocol_snapshot(&self) -> InternalWorkerSessionSnapshot {
|
pub(crate) fn protocol_snapshot(&self) -> InternalWorkerSessionSnapshot {
|
||||||
@@ -681,7 +673,6 @@ pub(crate) fn prepare_internal_worker_from_spec(
|
|||||||
} = spec;
|
} = spec;
|
||||||
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
|
manifest.worker.name = format!("internal-{}-{}", identity.kind, identity.run_id);
|
||||||
manifest.feature = Default::default();
|
manifest.feature = Default::default();
|
||||||
manifest.plugins = Default::default();
|
|
||||||
manifest.mcp = Default::default();
|
manifest.mcp = Default::default();
|
||||||
manifest.skills = None;
|
manifest.skills = None;
|
||||||
manifest.compaction = None;
|
manifest.compaction = None;
|
||||||
@@ -805,13 +796,11 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
let status = Arc::new(std::sync::atomic::AtomicU8::new(
|
let status = Arc::new(std::sync::atomic::AtomicU8::new(
|
||||||
InternalWorkerSessionStatus::Idle.encode(),
|
InternalWorkerSessionStatus::Idle.encode(),
|
||||||
));
|
));
|
||||||
let state_revision = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
|
||||||
let state_changed = Arc::new(tokio::sync::Notify::new());
|
let state_changed = Arc::new(tokio::sync::Notify::new());
|
||||||
let last_error = Arc::new(Mutex::new(None));
|
let last_error = Arc::new(Mutex::new(None));
|
||||||
let handle = InternalWorkerSessionHandle {
|
let handle = InternalWorkerSessionHandle {
|
||||||
command_tx,
|
command_tx,
|
||||||
status: status.clone(),
|
status: status.clone(),
|
||||||
state_revision: state_revision.clone(),
|
|
||||||
store,
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
segment_id,
|
segment_id,
|
||||||
@@ -847,11 +836,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
message,
|
message,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
send_internal_worker_state(
|
send_internal_worker_state(&event_tx, turn_status);
|
||||||
&event_tx,
|
|
||||||
&state_revision,
|
|
||||||
turn_status,
|
|
||||||
);
|
|
||||||
if let Some(callback) = &on_turn_end {
|
if let Some(callback) = &on_turn_end {
|
||||||
callback(turn_status);
|
callback(turn_status);
|
||||||
}
|
}
|
||||||
@@ -893,11 +878,7 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
InternalWorkerSessionStatus::Stopped.encode(),
|
InternalWorkerSessionStatus::Stopped.encode(),
|
||||||
std::sync::atomic::Ordering::Release,
|
std::sync::atomic::Ordering::Release,
|
||||||
);
|
);
|
||||||
send_internal_worker_state(
|
send_internal_worker_state(&event_tx, InternalWorkerSessionStatus::Stopped);
|
||||||
&event_tx,
|
|
||||||
&state_revision,
|
|
||||||
InternalWorkerSessionStatus::Stopped,
|
|
||||||
);
|
|
||||||
let _ = event_tx.send(Event::Shutdown);
|
let _ = event_tx.send(Event::Shutdown);
|
||||||
state_changed.notify_waiters();
|
state_changed.notify_waiters();
|
||||||
if let Some(done) = stop_done {
|
if let Some(done) = stop_done {
|
||||||
@@ -1149,7 +1130,6 @@ pub(crate) fn test_internal_worker_session(
|
|||||||
status: Arc::new(std::sync::atomic::AtomicU8::new(
|
status: Arc::new(std::sync::atomic::AtomicU8::new(
|
||||||
InternalWorkerSessionStatus::Idle.encode(),
|
InternalWorkerSessionStatus::Idle.encode(),
|
||||||
)),
|
)),
|
||||||
state_revision: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
|
||||||
store,
|
store,
|
||||||
session_id,
|
session_id,
|
||||||
segment_id,
|
segment_id,
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use arc_swap::ArcSwap;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
use crate::compact::state::CompactState;
|
use crate::compact::state::{AutomaticCompactDecision, CompactState};
|
||||||
use crate::compact::usage_tracker::UsageTracker;
|
use crate::compact::usage_tracker::UsageTracker;
|
||||||
use session_store::SystemItem;
|
use session_store::SystemItem;
|
||||||
|
|
||||||
@@ -111,6 +111,9 @@ pub(crate) struct WorkerInterceptor {
|
|||||||
tool_calls_this_turn: AtomicUsize,
|
tool_calls_this_turn: AtomicUsize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const THRESHOLD_COMPACT_BLOCKED_DIAGNOSTIC: &str =
|
||||||
|
"automatic compaction could not make the provider request context safe";
|
||||||
|
|
||||||
impl WorkerInterceptor {
|
impl WorkerInterceptor {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
@@ -229,27 +232,45 @@ impl WorkerInterceptor {
|
|||||||
Some(total_tokens(context, &records).tokens)
|
Some(total_tokens(context, &records).tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn request_threshold_exceeded(&self, current_tokens: Option<u64>, context: &[Item]) -> bool {
|
fn request_compact_decision(
|
||||||
if let Some(state) = self.compact_state.as_ref() {
|
&self,
|
||||||
if !state.is_disabled() && !state.just_compacted() {
|
current_tokens: Option<u64>,
|
||||||
let current = current_tokens.unwrap_or(0);
|
context: &[Item],
|
||||||
if state.exceeds_request(current) {
|
) -> AutomaticCompactDecision {
|
||||||
let shape = context_shape(context);
|
let Some(state) = self.compact_state.as_ref() else {
|
||||||
info!(
|
return AutomaticCompactDecision::Continue;
|
||||||
input_tokens = current,
|
};
|
||||||
threshold = state.request_threshold().unwrap_or(0),
|
let current = current_tokens.unwrap_or(0);
|
||||||
items_len = shape.items_len,
|
let decision = state.evaluate_request(current);
|
||||||
items_json_bytes = shape.items_json_bytes,
|
if !matches!(decision, AutomaticCompactDecision::Continue) {
|
||||||
reasoning_items = shape.reasoning_items,
|
let shape = context_shape(context);
|
||||||
reasoning_encrypted_content_count = shape.reasoning_encrypted_content_count,
|
info!(
|
||||||
reasoning_encrypted_content_bytes = shape.reasoning_encrypted_content_bytes,
|
input_tokens = current,
|
||||||
"Between-requests compaction threshold exceeded, yielding"
|
?decision,
|
||||||
);
|
items_len = shape.items_len,
|
||||||
return true;
|
items_json_bytes = shape.items_json_bytes,
|
||||||
|
reasoning_items = shape.reasoning_items,
|
||||||
|
reasoning_encrypted_content_count = shape.reasoning_encrypted_content_count,
|
||||||
|
reasoning_encrypted_content_bytes = shape.reasoning_encrypted_content_bytes,
|
||||||
|
"Between-requests automatic compaction decision"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
decision
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decision_action(&self, decision: AutomaticCompactDecision) -> Option<PreRequestAction> {
|
||||||
|
match decision {
|
||||||
|
AutomaticCompactDecision::Continue => None,
|
||||||
|
AutomaticCompactDecision::Start(_) => Some(PreRequestAction::Yield),
|
||||||
|
AutomaticCompactDecision::Block(block) => {
|
||||||
|
if let Some(state) = &self.compact_state {
|
||||||
|
state.record_request_block(block);
|
||||||
}
|
}
|
||||||
|
Some(PreRequestAction::Cancel(
|
||||||
|
THRESHOLD_COMPACT_BLOCKED_DIAGNOSTIC.to_string(),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
false
|
|
||||||
}
|
}
|
||||||
fn attach_prompt_provenance(&self, items: &mut [SystemItem]) {
|
fn attach_prompt_provenance(&self, items: &mut [SystemItem]) {
|
||||||
let prompts = self.prompts.load();
|
let prompts = self.prompts.load();
|
||||||
@@ -388,8 +409,10 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
|||||||
) -> InterceptorResult<PreRequestAction> {
|
) -> InterceptorResult<PreRequestAction> {
|
||||||
let context = context.items;
|
let context = context.items;
|
||||||
let initial_tokens = self.estimated_tokens(context);
|
let initial_tokens = self.estimated_tokens(context);
|
||||||
if self.request_threshold_exceeded(initial_tokens, context) {
|
if let Some(action) =
|
||||||
return Ok(PreRequestAction::Yield);
|
self.decision_action(self.request_compact_decision(initial_tokens, context))
|
||||||
|
{
|
||||||
|
return Ok(action);
|
||||||
}
|
}
|
||||||
let info = PreRequestInfo {
|
let info = PreRequestInfo {
|
||||||
item_count: context.len(),
|
item_count: context.len(),
|
||||||
@@ -423,6 +446,22 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
|||||||
return Ok(PreRequestAction::Cancel(reason));
|
return Ok(PreRequestAction::Cancel(reason));
|
||||||
}
|
}
|
||||||
if should_yield {
|
if should_yield {
|
||||||
|
if let Some(state) = &self.compact_state {
|
||||||
|
match state.claim_hook_yield() {
|
||||||
|
AutomaticCompactDecision::Start(_) => {}
|
||||||
|
AutomaticCompactDecision::Block(block) => {
|
||||||
|
state.record_request_block(block);
|
||||||
|
return Ok(PreRequestAction::Cancel(
|
||||||
|
THRESHOLD_COMPACT_BLOCKED_DIAGNOSTIC.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
AutomaticCompactDecision::Continue => {
|
||||||
|
return Ok(PreRequestAction::Cancel(
|
||||||
|
THRESHOLD_COMPACT_BLOCKED_DIAGNOSTIC.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return Ok(PreRequestAction::Yield);
|
return Ok(PreRequestAction::Yield);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,16 +484,26 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
|||||||
};
|
};
|
||||||
let current_tokens = self.estimated_tokens(effective_context.as_ref());
|
let current_tokens = self.estimated_tokens(effective_context.as_ref());
|
||||||
|
|
||||||
if self.request_threshold_exceeded(current_tokens, effective_context.as_ref()) {
|
let compact_decision =
|
||||||
|
self.request_compact_decision(current_tokens, effective_context.as_ref());
|
||||||
|
if !matches!(compact_decision, AutomaticCompactDecision::Continue) {
|
||||||
if let Err(error) = self.commit_system_items(&system_items) {
|
if let Err(error) = self.commit_system_items(&system_items) {
|
||||||
return Ok(PreRequestAction::Cancel(format!(
|
return Ok(PreRequestAction::Cancel(format!(
|
||||||
"session persistence failed: {error}"
|
"session persistence failed: {error}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
return Ok(if appended_items.is_empty() {
|
return Ok(match compact_decision {
|
||||||
PreRequestAction::Yield
|
AutomaticCompactDecision::Start(_) if !appended_items.is_empty() => {
|
||||||
} else {
|
PreRequestAction::YieldWith(appended_items)
|
||||||
PreRequestAction::YieldWith(appended_items)
|
}
|
||||||
|
AutomaticCompactDecision::Start(_) => PreRequestAction::Yield,
|
||||||
|
AutomaticCompactDecision::Block(block) => {
|
||||||
|
if let Some(state) = &self.compact_state {
|
||||||
|
state.record_request_block(block);
|
||||||
|
}
|
||||||
|
PreRequestAction::Cancel(THRESHOLD_COMPACT_BLOCKED_DIAGNOSTIC.to_string())
|
||||||
|
}
|
||||||
|
AutomaticCompactDecision::Continue => unreachable!(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,6 +717,18 @@ mod tests {
|
|||||||
Arc::new(builder.build())
|
Arc::new(builder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct YieldingPreRequestHook;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Hook<PreLlmRequest> for YieldingPreRequestHook {
|
||||||
|
async fn call(
|
||||||
|
&self,
|
||||||
|
_info: &PreRequestContext,
|
||||||
|
) -> Result<HookPreRequestAction, crate::hook::HookError> {
|
||||||
|
Ok(HookPreRequestAction::Yield)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct RecordingSystemItemCommitter {
|
struct RecordingSystemItemCommitter {
|
||||||
committed: Arc<Mutex<Vec<SystemItem>>>,
|
committed: Arc<Mutex<Vec<SystemItem>>>,
|
||||||
}
|
}
|
||||||
@@ -750,6 +811,36 @@ mod tests {
|
|||||||
}]))
|
}]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hook_yield_claims_attempt_before_returning_to_compaction() {
|
||||||
|
let mut builder = HookRegistryBuilder::new();
|
||||||
|
builder.add_pre_llm_request(YieldingPreRequestHook);
|
||||||
|
let registry = Arc::new(builder.build());
|
||||||
|
let state = Arc::new(CompactState::new(None, Some(u64::MAX), 0));
|
||||||
|
let interceptor = WorkerInterceptor::new(
|
||||||
|
registry,
|
||||||
|
Some(Arc::clone(&state)),
|
||||||
|
Some(usage_handle_with(1, 1)),
|
||||||
|
NotifyBuffer::new(),
|
||||||
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
test_prompts(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let mut ctx = vec![Item::user_message("hello")];
|
||||||
|
|
||||||
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(matches!(action, PreRequestAction::Yield));
|
||||||
|
assert!(state.has_claimed_attempt());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pre_llm_request_yields_and_skips_hooks_when_request_threshold_exceeded() {
|
async fn pre_llm_request_yields_and_skips_hooks_when_request_threshold_exceeded() {
|
||||||
let count = Arc::new(AtomicUsize::new(0));
|
let count = Arc::new(AtomicUsize::new(0));
|
||||||
@@ -761,7 +852,7 @@ mod tests {
|
|||||||
|
|
||||||
let interceptor = WorkerInterceptor::new(
|
let interceptor = WorkerInterceptor::new(
|
||||||
registry,
|
registry,
|
||||||
Some(state),
|
Some(Arc::clone(&state)),
|
||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
@@ -779,6 +870,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
assert!(matches!(action, PreRequestAction::Yield));
|
assert!(matches!(action, PreRequestAction::Yield));
|
||||||
|
assert!(state.has_claimed_attempt());
|
||||||
// Hook must not run when an internal mechanism short-circuits first.
|
// Hook must not run when an internal mechanism short-circuits first.
|
||||||
assert_eq!(count.load(Ordering::Relaxed), 0);
|
assert_eq!(count.load(Ordering::Relaxed), 0);
|
||||||
}
|
}
|
||||||
@@ -798,7 +890,7 @@ mod tests {
|
|||||||
|
|
||||||
let interceptor = WorkerInterceptor::new(
|
let interceptor = WorkerInterceptor::new(
|
||||||
registry,
|
registry,
|
||||||
Some(state),
|
Some(Arc::clone(&state)),
|
||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
@@ -821,10 +913,48 @@ mod tests {
|
|||||||
PreRequestAction::YieldWith(items) => assert_eq!(items.len(), 1),
|
PreRequestAction::YieldWith(items) => assert_eq!(items.len(), 1),
|
||||||
other => panic!("expected YieldWith queued system item, got {other:?}"),
|
other => panic!("expected YieldWith queued system item, got {other:?}"),
|
||||||
}
|
}
|
||||||
|
assert!(state.has_claimed_attempt());
|
||||||
assert!(saw_handle.load(Ordering::Relaxed));
|
assert!(saw_handle.load(Ordering::Relaxed));
|
||||||
assert_eq!(committed.lock().expect("committed system items").len(), 1);
|
assert_eq!(committed.lock().expect("committed system items").len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn successful_compaction_blocks_unsafe_request_until_usage_commit() {
|
||||||
|
let registry = Arc::new(HookRegistryBuilder::new().build());
|
||||||
|
let state = Arc::new(CompactState::new(None, Some(10), 0));
|
||||||
|
assert!(matches!(
|
||||||
|
state.evaluate_request(11),
|
||||||
|
AutomaticCompactDecision::Start(_)
|
||||||
|
));
|
||||||
|
assert!(state.complete_automatic(crate::compact::state::CompactionOutcome::Succeeded));
|
||||||
|
let history = usage_handle_with(1, 11);
|
||||||
|
let interceptor = WorkerInterceptor::new(
|
||||||
|
registry,
|
||||||
|
Some(Arc::clone(&state)),
|
||||||
|
Some(history),
|
||||||
|
NotifyBuffer::new(),
|
||||||
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
test_prompts(),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
let mut ctx = vec![Item::user_message("still too large")];
|
||||||
|
|
||||||
|
let action = interceptor
|
||||||
|
.pre_llm_request(PreLlmRequestContext {
|
||||||
|
invocation: Default::default(),
|
||||||
|
items: &mut ctx,
|
||||||
|
history: &[],
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("pre-request interception should succeed");
|
||||||
|
|
||||||
|
assert!(matches!(action, PreRequestAction::Cancel(_)));
|
||||||
|
assert_eq!(
|
||||||
|
state.take_pending_request_block(),
|
||||||
|
Some(crate::compact::state::AutomaticCompactBlock::Thrash)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pre_llm_request_counts_in_flight_usage_records() {
|
async fn pre_llm_request_counts_in_flight_usage_records() {
|
||||||
let registry = Arc::new(HookRegistryBuilder::new().build());
|
let registry = Arc::new(HookRegistryBuilder::new().build());
|
||||||
@@ -843,7 +973,7 @@ mod tests {
|
|||||||
|
|
||||||
let interceptor = WorkerInterceptor::new(
|
let interceptor = WorkerInterceptor::new(
|
||||||
registry,
|
registry,
|
||||||
Some(state),
|
Some(Arc::clone(&state)),
|
||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
@@ -875,7 +1005,7 @@ mod tests {
|
|||||||
|
|
||||||
let interceptor = WorkerInterceptor::new(
|
let interceptor = WorkerInterceptor::new(
|
||||||
registry,
|
registry,
|
||||||
Some(state),
|
Some(Arc::clone(&state)),
|
||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
@@ -923,7 +1053,7 @@ mod tests {
|
|||||||
let history = Arc::new(Mutex::new(vec![record]));
|
let history = Arc::new(Mutex::new(vec![record]));
|
||||||
let interceptor = WorkerInterceptor::new(
|
let interceptor = WorkerInterceptor::new(
|
||||||
registry,
|
registry,
|
||||||
Some(state),
|
Some(Arc::clone(&state)),
|
||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
@@ -957,7 +1087,7 @@ mod tests {
|
|||||||
|
|
||||||
let interceptor = WorkerInterceptor::new(
|
let interceptor = WorkerInterceptor::new(
|
||||||
registry,
|
registry,
|
||||||
Some(state),
|
Some(Arc::clone(&state)),
|
||||||
Some(history),
|
Some(history),
|
||||||
NotifyBuffer::new(),
|
NotifyBuffer::new(),
|
||||||
Arc::new(Mutex::new(Vec::new())),
|
Arc::new(Mutex::new(Vec::new())),
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||||
use tokio::net::UnixListener;
|
use tokio::net::UnixListener;
|
||||||
use tokio::task::JoinHandle;
|
use tokio::sync::oneshot;
|
||||||
|
use tokio::task::{JoinHandle, JoinSet};
|
||||||
|
|
||||||
use crate::controller::WorkerHandle;
|
use crate::controller::WorkerHandle;
|
||||||
use crate::ipc::protocol_session::{
|
use crate::ipc::protocol_session::{
|
||||||
@@ -19,7 +20,8 @@ use protocol::{ErrorCode, Event};
|
|||||||
/// - Client writes Method lines → forwarded to WorkerController
|
/// - Client writes Method lines → forwarded to WorkerController
|
||||||
/// - Worker events → written as Event lines to all connected clients
|
/// - Worker events → written as Event lines to all connected clients
|
||||||
pub struct SocketServer {
|
pub struct SocketServer {
|
||||||
_accept_task: JoinHandle<()>,
|
accept_task: Option<JoinHandle<()>>,
|
||||||
|
shutdown: Option<oneshot::Sender<()>>,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,20 +35,45 @@ impl SocketServer {
|
|||||||
|
|
||||||
let listener = UnixListener::bind(&path)?;
|
let listener = UnixListener::bind(&path)?;
|
||||||
let handle = handle.clone();
|
let handle = handle.clone();
|
||||||
|
let (shutdown_tx, mut shutdown_rx) = oneshot::channel();
|
||||||
|
|
||||||
let _accept_task = tokio::spawn(async move {
|
let accept_task = tokio::spawn(async move {
|
||||||
|
let mut connections = JoinSet::new();
|
||||||
loop {
|
loop {
|
||||||
match listener.accept().await {
|
tokio::select! {
|
||||||
Ok((stream, _)) => {
|
_ = &mut shutdown_rx => break,
|
||||||
let handle = handle.clone();
|
accepted = listener.accept() => match accepted {
|
||||||
tokio::spawn(handle_connection(stream, handle));
|
Ok((stream, _)) => {
|
||||||
|
let handle = handle.clone();
|
||||||
|
connections.spawn(handle_connection(stream, handle));
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
},
|
||||||
|
completed = connections.join_next(), if !connections.is_empty() => {
|
||||||
|
let _ = completed;
|
||||||
}
|
}
|
||||||
Err(_) => break,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
connections.shutdown().await;
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(Self { _accept_task, path })
|
Ok(Self {
|
||||||
|
accept_task: Some(accept_task),
|
||||||
|
shutdown: Some(shutdown_tx),
|
||||||
|
path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop accepting connections and join the server task. Dropping its
|
||||||
|
/// `JoinSet` cancels every active connection task before this returns.
|
||||||
|
pub async fn shutdown(mut self) {
|
||||||
|
if let Some(shutdown) = self.shutdown.take() {
|
||||||
|
let _ = shutdown.send(());
|
||||||
|
}
|
||||||
|
if let Some(task) = self.accept_task.take() {
|
||||||
|
let _ = task.await;
|
||||||
|
}
|
||||||
|
let _ = tokio::fs::remove_file(&self.path).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The socket file path.
|
/// The socket file path.
|
||||||
@@ -57,6 +84,10 @@ impl SocketServer {
|
|||||||
|
|
||||||
impl Drop for SocketServer {
|
impl Drop for SocketServer {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
|
if let Some(shutdown) = self.shutdown.take() {
|
||||||
|
let _ = shutdown.send(());
|
||||||
|
}
|
||||||
|
let _ = self.accept_task.take();
|
||||||
let _ = std::fs::remove_file(&self.path);
|
let _ = std::fs::remove_file(&self.path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ mod tests {
|
|||||||
target: "/tmp/work".into(),
|
target: "/tmp/work".into(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
callback_address: "/run/yoi/my-worker/sock".into(),
|
callback_address: "/run/yoi/my-worker/sock".into(),
|
||||||
}];
|
}];
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ pub(crate) fn write_rule(path: &str, recursive: bool) -> ScopeRule {
|
|||||||
target: PathBuf::from(path),
|
target: PathBuf::from(path),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive,
|
recursive,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +86,7 @@ pub(crate) fn read_rule(path: &str, recursive: bool) -> ScopeRule {
|
|||||||
target: PathBuf::from(path),
|
target: PathBuf::from(path),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive,
|
recursive,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ pub(crate) enum WorkerCommandAdmission {
|
|||||||
Retry,
|
Retry,
|
||||||
Conflict,
|
Conflict,
|
||||||
StaleCommandId,
|
StaleCommandId,
|
||||||
ExecutionGenerationMismatch,
|
|
||||||
StateRevisionMismatch,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared state between WorkerController and runtime directory.
|
/// Shared state between WorkerController and runtime directory.
|
||||||
@@ -59,23 +57,13 @@ impl WorkerSharedState {
|
|||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
manifest_toml: String,
|
manifest_toml: String,
|
||||||
greeting: protocol::Greeting,
|
greeting: protocol::Greeting,
|
||||||
) -> Self {
|
|
||||||
Self::new_with_generation(worker_name, segment_id, manifest_toml, greeting, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_with_generation(
|
|
||||||
worker_name: String,
|
|
||||||
segment_id: SegmentId,
|
|
||||||
manifest_toml: String,
|
|
||||||
greeting: protocol::Greeting,
|
|
||||||
execution_generation: u64,
|
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
worker_name,
|
worker_name,
|
||||||
segment_id,
|
segment_id,
|
||||||
manifest_toml,
|
manifest_toml,
|
||||||
greeting,
|
greeting,
|
||||||
state: RwLock::new(WorkerStateSnapshot::initial(execution_generation)),
|
state: RwLock::new(WorkerStateSnapshot::initial()),
|
||||||
accepted_commands: RwLock::new(VecDeque::new()),
|
accepted_commands: RwLock::new(VecDeque::new()),
|
||||||
fs_view: OnceLock::new(),
|
fs_view: OnceLock::new(),
|
||||||
flow_transition_enabled: AtomicBool::new(false),
|
flow_transition_enabled: AtomicBool::new(false),
|
||||||
@@ -108,7 +96,6 @@ impl WorkerSharedState {
|
|||||||
.write()
|
.write()
|
||||||
.expect("worker state lock poisoned; refusing an inferred fallback state");
|
.expect("worker state lock poisoned; refusing an inferred fallback state");
|
||||||
if snapshot.state != state {
|
if snapshot.state != state {
|
||||||
snapshot.revision = snapshot.revision.saturating_add(1);
|
|
||||||
snapshot.state = state;
|
snapshot.state = state;
|
||||||
}
|
}
|
||||||
snapshot.clone()
|
snapshot.clone()
|
||||||
@@ -118,7 +105,6 @@ impl WorkerSharedState {
|
|||||||
&self,
|
&self,
|
||||||
envelope: WorkerCommandEnvelope,
|
envelope: WorkerCommandEnvelope,
|
||||||
kind: WorkerCommandKind,
|
kind: WorkerCommandKind,
|
||||||
require_state_revision: bool,
|
|
||||||
) -> WorkerCommandAdmission {
|
) -> WorkerCommandAdmission {
|
||||||
let mut snapshot = self
|
let mut snapshot = self
|
||||||
.state
|
.state
|
||||||
@@ -138,18 +124,11 @@ impl WorkerSharedState {
|
|||||||
WorkerCommandAdmission::Conflict
|
WorkerCommandAdmission::Conflict
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if envelope.expected_execution_generation != snapshot.execution_generation {
|
|
||||||
return WorkerCommandAdmission::ExecutionGenerationMismatch;
|
|
||||||
}
|
|
||||||
if require_state_revision && envelope.expected_worker_state_revision != snapshot.revision {
|
|
||||||
return WorkerCommandAdmission::StateRevisionMismatch;
|
|
||||||
}
|
|
||||||
if envelope.command_id <= snapshot.last_command_id {
|
if envelope.command_id <= snapshot.last_command_id {
|
||||||
return WorkerCommandAdmission::StaleCommandId;
|
return WorkerCommandAdmission::StaleCommandId;
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot.last_command_id = envelope.command_id;
|
snapshot.last_command_id = envelope.command_id;
|
||||||
snapshot.revision = snapshot.revision.saturating_add(1);
|
|
||||||
accepted.push_back(AcceptedWorkerCommand {
|
accepted.push_back(AcceptedWorkerCommand {
|
||||||
envelope,
|
envelope,
|
||||||
kind,
|
kind,
|
||||||
@@ -248,12 +227,11 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn test_state() -> WorkerSharedState {
|
fn test_state() -> WorkerSharedState {
|
||||||
WorkerSharedState::new_with_generation(
|
WorkerSharedState::new(
|
||||||
"test-worker".into(),
|
"test-worker".into(),
|
||||||
session_store::new_segment_id(),
|
session_store::new_segment_id(),
|
||||||
"[engine]\nname = \"test-worker\"".into(),
|
"[engine]\nname = \"test-worker\"".into(),
|
||||||
test_greeting(),
|
test_greeting(),
|
||||||
7,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,43 +251,34 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn initial_snapshot_is_idle() {
|
fn initial_snapshot_is_idle() {
|
||||||
let state = test_state();
|
let state = test_state();
|
||||||
assert_eq!(state.snapshot(), WorkerStateSnapshot::initial(7));
|
assert_eq!(state.snapshot(), WorkerStateSnapshot::initial());
|
||||||
assert_eq!(state.catalog_status(), WorkerStatus::Idle);
|
assert_eq!(state.catalog_status(), WorkerStatus::Idle);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn transitions_increment_revision_only_when_state_changes() {
|
fn transitions_publish_full_state() {
|
||||||
let state = test_state();
|
let state = test_state();
|
||||||
let running = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running));
|
let running = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Running));
|
||||||
let snapshot = state.transition(running.clone());
|
assert_eq!(state.transition(running.clone()).state, running);
|
||||||
assert_eq!(snapshot.revision, 1);
|
|
||||||
assert_eq!(snapshot.state, running);
|
|
||||||
assert_eq!(state.transition(running).revision, 1);
|
|
||||||
|
|
||||||
let paused = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused));
|
let paused = WorkerState::Busy(WorkerBusyState::Run(WorkerRunState::Paused));
|
||||||
let snapshot = state.transition(paused.clone());
|
let snapshot = state.transition(paused.clone());
|
||||||
assert_eq!(snapshot.revision, 2);
|
|
||||||
assert_eq!(snapshot.state, paused);
|
assert_eq!(snapshot.state, paused);
|
||||||
|
assert_eq!(snapshot.last_command_id, 0);
|
||||||
assert_eq!(state.catalog_status(), WorkerStatus::Paused);
|
assert_eq!(state.catalog_status(), WorkerStatus::Paused);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepted_command_identity_advances_revision_and_detects_reuse_conflicts() {
|
fn accepted_command_identity_advances_last_id_and_detects_reuse_conflicts() {
|
||||||
let state = test_state();
|
let state = test_state();
|
||||||
let envelope = WorkerCommandEnvelope {
|
let envelope = WorkerCommandEnvelope { command_id: 9 };
|
||||||
command_id: 9,
|
|
||||||
expected_execution_generation: 7,
|
|
||||||
expected_worker_state_revision: 0,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.admit_command(envelope, WorkerCommandKind::Pause, true),
|
state.admit_command(envelope, WorkerCommandKind::Pause),
|
||||||
WorkerCommandAdmission::Accepted
|
WorkerCommandAdmission::Accepted
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.snapshot(),
|
state.snapshot(),
|
||||||
WorkerStateSnapshot {
|
WorkerStateSnapshot {
|
||||||
execution_generation: 7,
|
|
||||||
revision: 1,
|
|
||||||
last_command_id: 9,
|
last_command_id: 9,
|
||||||
state: WorkerState::Idle,
|
state: WorkerState::Idle,
|
||||||
}
|
}
|
||||||
@@ -325,14 +294,14 @@ mod tests {
|
|||||||
Some(Some(WorkerCommandDisposition::Accepted))
|
Some(Some(WorkerCommandDisposition::Accepted))
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.admit_command(envelope, WorkerCommandKind::Pause, true),
|
state.admit_command(envelope, WorkerCommandKind::Pause),
|
||||||
WorkerCommandAdmission::Retry
|
WorkerCommandAdmission::Retry
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
state.admit_command(envelope, WorkerCommandKind::Cancel, true),
|
state.admit_command(envelope, WorkerCommandKind::Cancel),
|
||||||
WorkerCommandAdmission::Conflict
|
WorkerCommandAdmission::Conflict
|
||||||
);
|
);
|
||||||
assert_eq!(state.snapshot().revision, 1);
|
assert_eq!(state.snapshot().last_command_id, 9);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -343,8 +312,8 @@ mod tests {
|
|||||||
)));
|
)));
|
||||||
let parsed: serde_json::Value = serde_json::from_str(&state.status_json()).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&state.status_json()).unwrap();
|
||||||
assert_eq!(parsed["state"], "running");
|
assert_eq!(parsed["state"], "running");
|
||||||
assert_eq!(parsed["worker_state"]["execution_generation"], 7);
|
assert!(parsed["worker_state"].get("execution_generation").is_none());
|
||||||
assert_eq!(parsed["worker_state"]["revision"], 1);
|
assert!(parsed["worker_state"].get("revision").is_none());
|
||||||
assert_eq!(parsed["worker_state"]["state"]["kind"], "busy");
|
assert_eq!(parsed["worker_state"]["state"]["kind"], "busy");
|
||||||
assert_eq!(parsed["worker_name"], "test-worker");
|
assert_eq!(parsed["worker_name"], "test-worker");
|
||||||
assert!(parsed["segment_id"].is_string());
|
assert!(parsed["segment_id"].is_string());
|
||||||
|
|||||||
@@ -138,10 +138,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let method = Method::Shutdown {
|
let method = Method::Shutdown {
|
||||||
command: protocol::WorkerCommandEnvelope::for_snapshot(
|
command: protocol::WorkerCommandEnvelope::new(1),
|
||||||
1,
|
|
||||||
&protocol::WorkerStateSnapshot::initial(1),
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
connect_and_send(&socket, &method).await.unwrap();
|
connect_and_send(&socket, &method).await.unwrap();
|
||||||
|
|
||||||
|
|||||||
@@ -1030,6 +1030,7 @@ fn record_from_worker_state(child: &WorkerSpawnedChild) -> io::Result<SpawnedWor
|
|||||||
target: rule.target.clone(),
|
target: rule.target.clone(),
|
||||||
permission,
|
permission,
|
||||||
recursive: rule.recursive,
|
recursive: rule.recursive,
|
||||||
|
symlink_policy: rule.symlink_policy,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect::<io::Result<Vec<_>>>()?;
|
.collect::<io::Result<Vec<_>>>()?;
|
||||||
@@ -1072,6 +1073,7 @@ mod tests {
|
|||||||
target: std::path::PathBuf::from("/tmp"),
|
target: std::path::PathBuf::from("/tmp"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
})
|
})
|
||||||
@@ -1090,6 +1092,7 @@ mod tests {
|
|||||||
target: root.clone(),
|
target: root.clone(),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
})
|
})
|
||||||
@@ -1109,6 +1112,7 @@ mod tests {
|
|||||||
target: workdir::WorkdirPath::new("").unwrap(),
|
target: workdir::WorkdirPath::new("").unwrap(),
|
||||||
permission: workdir::WorkdirToolScopePermission::Read,
|
permission: workdir::WorkdirToolScopePermission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
cwd: workdir::WorkdirPath::new("").unwrap(),
|
cwd: workdir::WorkdirPath::new("").unwrap(),
|
||||||
command: false,
|
command: false,
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ use manifest::{
|
|||||||
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
|
CompactionConfigPartial, EngineManifestConfig, FileUploadLimitsPartial,
|
||||||
PermissionConfigPartial, ProfileDiscovery, ProfileError, ProfileRegistry,
|
PermissionConfigPartial, ProfileDiscovery, ProfileError, ProfileRegistry,
|
||||||
ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig,
|
ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ScopeConfig,
|
||||||
ScopeRule, SessionConfigPartial, ToolOutputLimitsPartial, WorkerManifest, WorkerManifestConfig,
|
ScopeRule, SessionConfigPartial, SymlinkPolicy, ToolOutputLimitsPartial, WorkerManifest,
|
||||||
WorkerMetaConfig,
|
WorkerManifestConfig, WorkerMetaConfig,
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
@@ -61,7 +61,9 @@ struct SubWorkerSpawnInput {
|
|||||||
task: String,
|
task: String,
|
||||||
/// Allow rules delegated to the spawned SubWorker. Must be a subset of the
|
/// Allow rules delegated to the spawned SubWorker. Must be a subset of the
|
||||||
/// spawner's explicit delegation authority; direct tool scope alone is not
|
/// spawner's explicit delegation authority; direct tool scope alone is not
|
||||||
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
|
/// sufficient. Omit `recursive` for normal workspace/worktree delegation;
|
||||||
|
/// it defaults to true. Omit `symlink_policy` for the least-authority
|
||||||
|
/// `resolved` policy; `logical` requires matching parent authority.
|
||||||
scope: Vec<ScopeRuleInput>,
|
scope: Vec<ScopeRuleInput>,
|
||||||
/// Explicitly grant command execution through the parent-owned Workdir tool broker.
|
/// Explicitly grant command execution through the parent-owned Workdir tool broker.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -88,6 +90,27 @@ struct ScopeRuleInput {
|
|||||||
/// children only. Defaults to `true`.
|
/// children only. Defaults to `true`.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
recursive: bool,
|
recursive: bool,
|
||||||
|
/// Symbolic-link identity used by this rule. `resolved` is the default
|
||||||
|
/// and least authority; `logical` requires matching parent authority.
|
||||||
|
#[serde(default)]
|
||||||
|
symlink_policy: SymlinkPolicyInput,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default, Deserialize, schemars::JsonSchema, Clone, Copy)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
enum SymlinkPolicyInput {
|
||||||
|
#[default]
|
||||||
|
Resolved,
|
||||||
|
Logical,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SymlinkPolicyInput> for SymlinkPolicy {
|
||||||
|
fn from(value: SymlinkPolicyInput) -> Self {
|
||||||
|
match value {
|
||||||
|
SymlinkPolicyInput::Resolved => Self::Resolved,
|
||||||
|
SymlinkPolicyInput::Logical => Self::Logical,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, schemars::JsonSchema, Clone, Copy)]
|
#[derive(Debug, Deserialize, schemars::JsonSchema, Clone, Copy)]
|
||||||
@@ -506,6 +529,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
target: child_bash_output_dir.clone(),
|
target: child_bash_output_dir.clone(),
|
||||||
permission: manifest::Permission::Read,
|
permission: manifest::Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}])
|
}])
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
ToolError::ExecutionFailed(format!(
|
ToolError::ExecutionFailed(format!(
|
||||||
@@ -707,6 +731,7 @@ fn parse_workdir_scope(rules: &[ScopeRuleInput]) -> Result<Vec<WorkdirToolScopeR
|
|||||||
PermissionInput::Write => WorkdirToolScopePermission::Write,
|
PermissionInput::Write => WorkdirToolScopePermission::Write,
|
||||||
},
|
},
|
||||||
recursive: rule.recursive,
|
recursive: rule.recursive,
|
||||||
|
symlink_policy: rule.symlink_policy.into(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -926,7 +951,6 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
|
|||||||
rules: p.rules.clone(),
|
rules: p.rules.clone(),
|
||||||
}),
|
}),
|
||||||
feature: manifest.feature.clone().into(),
|
feature: manifest.feature.clone().into(),
|
||||||
plugins: manifest.plugins.clone(),
|
|
||||||
mcp: manifest.mcp.clone(),
|
mcp: manifest.mcp.clone(),
|
||||||
compaction: manifest
|
compaction: manifest
|
||||||
.compaction
|
.compaction
|
||||||
@@ -1074,21 +1098,26 @@ mod tests {
|
|||||||
target: ".".to_string(),
|
target: ".".to_string(),
|
||||||
permission: PermissionInput::Read,
|
permission: PermissionInput::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
},
|
},
|
||||||
ScopeRuleInput {
|
ScopeRuleInput {
|
||||||
target: "src".to_string(),
|
target: "src".to_string(),
|
||||||
permission: PermissionInput::Write,
|
permission: PermissionInput::Write,
|
||||||
recursive: false,
|
recursive: false,
|
||||||
|
symlink_policy: SymlinkPolicyInput::Logical,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(rules[0].target.as_str(), "");
|
assert_eq!(rules[0].target.as_str(), "");
|
||||||
assert_eq!(rules[1].target.as_str(), "src");
|
assert_eq!(rules[1].target.as_str(), "src");
|
||||||
|
assert_eq!(rules[0].symlink_policy, SymlinkPolicy::Resolved);
|
||||||
|
assert_eq!(rules[1].symlink_policy, SymlinkPolicy::Logical);
|
||||||
for target in ["/host/path", "../escape"] {
|
for target in ["/host/path", "../escape"] {
|
||||||
let error = parse_workdir_scope(&[ScopeRuleInput {
|
let error = parse_workdir_scope(&[ScopeRuleInput {
|
||||||
target: target.to_string(),
|
target: target.to_string(),
|
||||||
permission: PermissionInput::Read,
|
permission: PermissionInput::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}])
|
}])
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(error, ToolError::InvalidArgument(_)));
|
assert!(matches!(error, ToolError::InvalidArgument(_)));
|
||||||
@@ -1126,6 +1155,7 @@ mod tests {
|
|||||||
target: path.to_path_buf(),
|
target: path.to_path_buf(),
|
||||||
permission,
|
permission,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1533,10 +1563,14 @@ enabled = false
|
|||||||
assert!(record.installed_tools.iter().any(|tool| tool == "Write"));
|
assert!(record.installed_tools.iter().any(|tool| tool == "Write"));
|
||||||
assert!(!record.installed_tools.iter().any(|tool| tool == "Bash"));
|
assert!(!record.installed_tools.iter().any(|tool| tool == "Bash"));
|
||||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||||
assert!(
|
let requests = remote_client.requests();
|
||||||
remote_client.requests().is_empty(),
|
assert!(!requests.is_empty());
|
||||||
"spawning a child must not open or delegate a provider Workdir session"
|
assert!(requests.iter().all(|request| {
|
||||||
);
|
let body = request.body.as_deref().unwrap_or_default();
|
||||||
|
body.contains("authorize_scope")
|
||||||
|
&& !body.contains(&bash_output_dir.display().to_string())
|
||||||
|
&& !body.contains(&workspace_root.display().to_string())
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1548,6 +1582,9 @@ enabled = false
|
|||||||
.expect("schema properties");
|
.expect("schema properties");
|
||||||
assert!(properties.contains_key("cwd"), "schema: {schema}");
|
assert!(properties.contains_key("cwd"), "schema: {schema}");
|
||||||
assert!(properties.contains_key("command"), "schema: {schema}");
|
assert!(properties.contains_key("command"), "schema: {schema}");
|
||||||
|
let schema_text = serde_json::to_string(&schema).unwrap();
|
||||||
|
assert!(schema_text.contains("symlink_policy"), "schema: {schema}");
|
||||||
|
assert!(schema_text.contains("logical"), "schema: {schema}");
|
||||||
let required = schema
|
let required = schema
|
||||||
.get("required")
|
.get("required")
|
||||||
.and_then(serde_json::Value::as_array)
|
.and_then(serde_json::Value::as_array)
|
||||||
@@ -1708,10 +1745,29 @@ enabled = false
|
|||||||
self.requests
|
self.requests
|
||||||
.lock()
|
.lock()
|
||||||
.expect("remote Workdir request lock")
|
.expect("remote Workdir request lock")
|
||||||
.push(request);
|
.push(request.clone());
|
||||||
Err(WorkspaceClientError::Request(
|
let operation: workdir::workspace::WorkspaceWorkdirSessionOperationRequest =
|
||||||
"SubWorker spawn must not call the remote Workdir provider".into(),
|
serde_json::from_str(request.body.as_deref().unwrap_or_default()).map_err(
|
||||||
))
|
|error| {
|
||||||
|
WorkspaceClientError::Request(format!(
|
||||||
|
"invalid remote Workdir operation: {error}"
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
match operation.operation {
|
||||||
|
workdir::http::WorkdirSessionOperation::AuthorizeScope(_) => {
|
||||||
|
Ok(WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: serde_json::to_string(
|
||||||
|
&workdir::http::WorkdirSessionOperationResult::AuthorizeScope,
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ => Err(WorkspaceClientError::Request(
|
||||||
|
"SubWorker spawn may only authorize its provider-side scope".into(),
|
||||||
|
)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1905,6 +1961,7 @@ max_tokens = 3333
|
|||||||
target: PathBuf::from("/tmp/child"),
|
target: PathBuf::from("/tmp/child"),
|
||||||
permission: Permission::Read,
|
permission: Permission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let config_json =
|
let config_json =
|
||||||
|
|||||||
+399
-357
File diff suppressed because it is too large
Load Diff
@@ -8,23 +8,112 @@
|
|||||||
|
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||||
|
|
||||||
use agen::Engine;
|
use agen::Engine;
|
||||||
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
|
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent, UsageEvent};
|
||||||
use agen::llm_client::types::Item;
|
use agen::llm_client::types::Item;
|
||||||
use agen::llm_client::{ClientError, LlmClient, Request};
|
use agen::llm_client::{ClientError, LlmClient, Request};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use futures::Stream;
|
use futures::Stream;
|
||||||
use protocol::{Event, Method, RunResult};
|
use protocol::{Event, Method, RunResult};
|
||||||
use session_store::{CombinedStore, FsWorkerStore, WorkerMetadataStore};
|
use session_store::{
|
||||||
use session_store::{FsStore, LogEntry, Store};
|
CombinedStore, FsStore, FsWorkerStore, LogEntry, Store, WorkerMetadata, WorkerMetadataStore,
|
||||||
|
WorkerStoreError,
|
||||||
|
};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
use worker::{Worker, WorkerController};
|
use worker::{Worker, WorkerController, WorkerError};
|
||||||
|
|
||||||
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
|
type TestStore = CombinedStore<FsStore, FsWorkerStore>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct FaultingWorkerMetadataStore {
|
||||||
|
inner: FsWorkerStore,
|
||||||
|
fail_next_update: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FaultingWorkerMetadataStore {
|
||||||
|
fn new(root: impl Into<std::path::PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: FsWorkerStore::new(root).unwrap(),
|
||||||
|
fail_next_update: Arc::new(AtomicBool::new(false)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arm_update_failure(&self) {
|
||||||
|
self.fail_next_update.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkerMetadataStore for FaultingWorkerMetadataStore {
|
||||||
|
fn write(&self, metadata: &WorkerMetadata) -> Result<(), WorkerStoreError> {
|
||||||
|
let old_segment_id = self
|
||||||
|
.inner
|
||||||
|
.read_by_name(&metadata.worker_name)?
|
||||||
|
.and_then(|current| current.active)
|
||||||
|
.and_then(|active| active.segment_id);
|
||||||
|
let new_segment_id = metadata
|
||||||
|
.active
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|active| active.segment_id);
|
||||||
|
if old_segment_id != new_segment_id && self.fail_next_update.swap(false, Ordering::SeqCst) {
|
||||||
|
return Err(WorkerStoreError::Io(std::io::Error::other(
|
||||||
|
"injected active Segment commit failure",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.inner.write(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_by_name(&self, worker_name: &str) -> Result<Option<WorkerMetadata>, WorkerStoreError> {
|
||||||
|
self.inner.read_by_name(worker_name)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_by_name<F>(
|
||||||
|
&self,
|
||||||
|
worker_name: &str,
|
||||||
|
mutate: F,
|
||||||
|
) -> Result<WorkerMetadata, WorkerStoreError>
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut WorkerMetadata),
|
||||||
|
{
|
||||||
|
let mut metadata = self
|
||||||
|
.inner
|
||||||
|
.read_by_name(worker_name)?
|
||||||
|
.unwrap_or_else(|| WorkerMetadata::new(worker_name, None));
|
||||||
|
let old_segment_id = metadata
|
||||||
|
.active
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|active| active.segment_id);
|
||||||
|
mutate(&mut metadata);
|
||||||
|
let new_segment_id = metadata
|
||||||
|
.active
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|active| active.segment_id);
|
||||||
|
if old_segment_id != new_segment_id && self.fail_next_update.swap(false, Ordering::SeqCst) {
|
||||||
|
return Err(WorkerStoreError::Io(std::io::Error::other(
|
||||||
|
"injected active Segment commit failure",
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.inner.write(&metadata)?;
|
||||||
|
Ok(metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_names(&self) -> Result<Vec<String>, WorkerStoreError> {
|
||||||
|
self.inner.list_names()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_dir(&self) -> Option<std::path::PathBuf> {
|
||||||
|
self.inner.root_dir()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete_by_name(&self, worker_name: &str) -> Result<(), WorkerStoreError> {
|
||||||
|
self.inner.delete_by_name(worker_name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FaultingTestStore = CombinedStore<FsStore, FaultingWorkerMetadataStore>;
|
||||||
|
|
||||||
fn annotated(item: Item) -> session_store::LoggedHistoryEntry {
|
fn annotated(item: Item) -> session_store::LoggedHistoryEntry {
|
||||||
session_store::LoggedHistoryEntry {
|
session_store::LoggedHistoryEntry {
|
||||||
item: session_store::LoggedItem::from(item),
|
item: session_store::LoggedItem::from(item),
|
||||||
@@ -145,6 +234,49 @@ fn write_summary_tool_use_events(call_id: &str, text: &str) -> Vec<LlmEvent> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_summary_tool_use_events_with_usage(
|
||||||
|
call_id: &str,
|
||||||
|
text: &str,
|
||||||
|
input_total: u64,
|
||||||
|
cache_read: u64,
|
||||||
|
cache_write: u64,
|
||||||
|
output: u64,
|
||||||
|
) -> Vec<LlmEvent> {
|
||||||
|
let mut events = write_summary_tool_use_events(call_id, text);
|
||||||
|
events.insert(
|
||||||
|
events.len() - 1,
|
||||||
|
LlmEvent::Usage(UsageEvent {
|
||||||
|
input_tokens: Some(input_total),
|
||||||
|
output_tokens: Some(output),
|
||||||
|
total_tokens: Some(input_total.saturating_add(output)),
|
||||||
|
cache_read_input_tokens: Some(cache_read),
|
||||||
|
cache_creation_input_tokens: Some(cache_write),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_events_with_full_usage(
|
||||||
|
text: &str,
|
||||||
|
input_total: u64,
|
||||||
|
cache_read: u64,
|
||||||
|
cache_write: u64,
|
||||||
|
output: u64,
|
||||||
|
) -> Vec<LlmEvent> {
|
||||||
|
let mut events = single_text_events(text);
|
||||||
|
events.insert(
|
||||||
|
events.len() - 1,
|
||||||
|
LlmEvent::Usage(UsageEvent {
|
||||||
|
input_tokens: Some(input_total),
|
||||||
|
output_tokens: Some(output),
|
||||||
|
total_tokens: Some(input_total.saturating_add(output)),
|
||||||
|
cache_read_input_tokens: Some(cache_read),
|
||||||
|
cache_creation_input_tokens: Some(cache_write),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
events
|
||||||
|
}
|
||||||
|
|
||||||
// A low compact_threshold guarantees `try_pre_run_compact` will fire
|
// A low compact_threshold guarantees `try_pre_run_compact` will fire
|
||||||
// the first time we check after a run.
|
// the first time we check after a run.
|
||||||
const POST_RUN_MANIFEST_TOML: &str = r#"
|
const POST_RUN_MANIFEST_TOML: &str = r#"
|
||||||
@@ -168,6 +300,27 @@ target = "./"
|
|||||||
permission = "write"
|
permission = "write"
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
|
const MANUAL_ONLY_MANIFEST_TOML: &str = r#"
|
||||||
|
[worker]
|
||||||
|
name = "test-worker"
|
||||||
|
pwd = "./"
|
||||||
|
|
||||||
|
[model]
|
||||||
|
scheme = "anthropic"
|
||||||
|
model_id = "test-model"
|
||||||
|
|
||||||
|
[engine]
|
||||||
|
max_tokens = 100
|
||||||
|
|
||||||
|
[compaction]
|
||||||
|
compact_threshold = 1000000000
|
||||||
|
compact_retained_tokens = 0
|
||||||
|
|
||||||
|
[[scope.allow]]
|
||||||
|
target = "./"
|
||||||
|
permission = "write"
|
||||||
|
"#;
|
||||||
|
|
||||||
// `compact_request_threshold` drives the WorkerInterceptor's mid-turn yield
|
// `compact_request_threshold` drives the WorkerInterceptor's mid-turn yield
|
||||||
// path. `compact_threshold` is left unset so the post-run check stays inert.
|
// path. `compact_threshold` is left unset so the post-run check stays inert.
|
||||||
const MID_TURN_MANIFEST_TOML: &str = r#"
|
const MID_TURN_MANIFEST_TOML: &str = r#"
|
||||||
@@ -229,6 +382,41 @@ async fn make_worker(client: MockClient) -> Worker<MockClient, TestStore> {
|
|||||||
make_worker_with_manifest(POST_RUN_MANIFEST_TOML, client).await
|
make_worker_with_manifest(POST_RUN_MANIFEST_TOML, client).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn make_faulting_worker(
|
||||||
|
client: MockClient,
|
||||||
|
) -> (
|
||||||
|
Worker<MockClient, FaultingTestStore>,
|
||||||
|
FaultingWorkerMetadataStore,
|
||||||
|
FsStore,
|
||||||
|
) {
|
||||||
|
let manifest = worker::WorkerManifest::from_toml(MID_TURN_MANIFEST_TOML).unwrap();
|
||||||
|
let store_tmp = tempfile::tempdir().unwrap();
|
||||||
|
let segment_store = FsStore::new(store_tmp.path()).unwrap();
|
||||||
|
let metadata_store = FaultingWorkerMetadataStore::new(store_tmp.path().join("pods"));
|
||||||
|
let store = CombinedStore::new(segment_store.clone(), metadata_store.clone());
|
||||||
|
std::mem::forget(store_tmp);
|
||||||
|
|
||||||
|
let pwd_tmp = tempfile::tempdir().unwrap();
|
||||||
|
let pwd = pwd_tmp.path().to_path_buf();
|
||||||
|
let scope = worker::Scope::writable(&pwd).unwrap();
|
||||||
|
std::mem::forget(pwd_tmp);
|
||||||
|
|
||||||
|
let engine =
|
||||||
|
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(client);
|
||||||
|
let mut worker = Worker::new(
|
||||||
|
manifest,
|
||||||
|
engine,
|
||||||
|
store,
|
||||||
|
worker::WorkerWorkspaceContext::local_filesystem(None),
|
||||||
|
worker::WorkerFilesystemAuthority::local(pwd.clone(), pwd.clone()),
|
||||||
|
scope,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
worker.enable_worker_metadata_write_through().unwrap();
|
||||||
|
(worker, metadata_store, segment_store)
|
||||||
|
}
|
||||||
|
|
||||||
/// Drain whatever events are already queued on `rx`. Non-blocking.
|
/// Drain whatever events are already queued on `rx`. Non-blocking.
|
||||||
fn drain(rx: &mut broadcast::Receiver<Event>) -> Vec<Event> {
|
fn drain(rx: &mut broadcast::Receiver<Event>) -> Vec<Event> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
@@ -282,6 +470,118 @@ fn system_texts_in_sink_session_start(
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn active_segment_cas_rejects_stale_compaction_writer() {
|
||||||
|
let client = MockClient::new(vec![
|
||||||
|
single_text_events("seed response"),
|
||||||
|
write_summary_tool_use_events("summary-1", "replacement summary"),
|
||||||
|
single_text_events("done"),
|
||||||
|
]);
|
||||||
|
let (mut worker, metadata_store, segment_store) = make_faulting_worker(client).await;
|
||||||
|
worker.run_text("seed input").await.unwrap();
|
||||||
|
let old_segment_id = worker.segment_id();
|
||||||
|
let session_id = worker.session_id();
|
||||||
|
let competing_segment_id = uuid::Uuid::now_v7();
|
||||||
|
metadata_store
|
||||||
|
.update_by_name("test-worker", |metadata| {
|
||||||
|
metadata.active.as_mut().unwrap().segment_id = Some(competing_segment_id);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = worker.compact(0).await.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(error, worker::WorkerError::CompactActiveSegmentChanged),
|
||||||
|
"unexpected stale CAS error: {error:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(worker.segment_id(), old_segment_id);
|
||||||
|
let failure_metrics =
|
||||||
|
session_metrics::read_segment_metrics(&segment_store, session_id, old_segment_id).unwrap();
|
||||||
|
let finish = failure_metrics
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.metric.name == "compact.finish")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(finish.metric.dimensions["outcome"], "failed");
|
||||||
|
assert_eq!(finish.metric.value, Some(1.0));
|
||||||
|
assert_eq!(
|
||||||
|
finish.metric.dimensions["failure_category"],
|
||||||
|
"active_segment_commit"
|
||||||
|
);
|
||||||
|
assert!(!finish.metric.dimensions.contains_key("error"));
|
||||||
|
assert_eq!(
|
||||||
|
metadata_store
|
||||||
|
.read_by_name("test-worker")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap()
|
||||||
|
.active
|
||||||
|
.unwrap()
|
||||||
|
.segment_id,
|
||||||
|
Some(competing_segment_id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_active_segment_commit_keeps_live_and_durable_history_on_old_segment() {
|
||||||
|
let client = MockClient::new(vec![
|
||||||
|
single_text_events("seed response"),
|
||||||
|
write_summary_tool_use_events("summary-1", "replacement summary"),
|
||||||
|
single_text_events("continued on old segment"),
|
||||||
|
]);
|
||||||
|
let (mut worker, metadata_store, segment_store) = make_faulting_worker(client).await;
|
||||||
|
worker.run_text("seed input").await.unwrap();
|
||||||
|
let old_segment_id = worker.segment_id();
|
||||||
|
|
||||||
|
metadata_store.arm_update_failure();
|
||||||
|
let error = worker.compact(0).await.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("injected active Segment commit failure")
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(worker.segment_id(), old_segment_id);
|
||||||
|
let failure_metrics =
|
||||||
|
session_metrics::read_segment_metrics(&segment_store, worker.session_id(), old_segment_id)
|
||||||
|
.unwrap();
|
||||||
|
let start = failure_metrics
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.metric.name == "compact.start")
|
||||||
|
.unwrap();
|
||||||
|
let finish = failure_metrics
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.metric.name == "compact.finish")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(finish.metric.dimensions["outcome"], "failed");
|
||||||
|
assert_eq!(finish.metric.value, Some(1.0));
|
||||||
|
assert_eq!(
|
||||||
|
finish.metric.dimensions["failure_category"],
|
||||||
|
"active_segment_commit"
|
||||||
|
);
|
||||||
|
assert_eq!(finish.metric.correlation_id, start.metric.correlation_id);
|
||||||
|
assert!(
|
||||||
|
!serde_json::to_string(&finish.metric)
|
||||||
|
.unwrap()
|
||||||
|
.contains("injected active Segment commit failure")
|
||||||
|
);
|
||||||
|
let metadata = metadata_store
|
||||||
|
.read_by_name("test-worker")
|
||||||
|
.unwrap()
|
||||||
|
.expect("active Worker metadata should remain present");
|
||||||
|
assert_eq!(
|
||||||
|
metadata.active.and_then(|active| active.segment_id),
|
||||||
|
Some(old_segment_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.run_text("continue input").await.unwrap();
|
||||||
|
let active_records = segment_store
|
||||||
|
.read_all(worker.session_id(), old_segment_id)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
format!("{active_records:?}").contains("continue input"),
|
||||||
|
"the live Worker must continue appending to the old active Segment"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Worker metadata starts with a reserved Session and no Segment, then becomes
|
/// Worker metadata starts with a reserved Session and no Segment, then becomes
|
||||||
/// active once the first SegmentStart is materialized by `run`.
|
/// active once the first SegmentStart is materialized by `run`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -411,16 +711,18 @@ permission = "write"
|
|||||||
async fn compact_emits_session_start_carrying_summary_and_task_snapshot() {
|
async fn compact_emits_session_start_carrying_summary_and_task_snapshot() {
|
||||||
let client = MockClient::new(vec![
|
let client = MockClient::new(vec![
|
||||||
single_text_events("hi"),
|
single_text_events("hi"),
|
||||||
write_summary_tool_use_events("call-1", "summary"),
|
write_summary_tool_use_events_with_usage("call-1", "summary", 100, 10, 5, 20),
|
||||||
single_text_events("done"),
|
text_events_with_full_usage("done", 50, 3, 2, 10),
|
||||||
|
text_events_with_full_usage("after", 44, 4, 1, 6),
|
||||||
]);
|
]);
|
||||||
let mut worker = make_worker(client).await;
|
let mut worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await;
|
||||||
|
|
||||||
let (tx, _rx_keep) = broadcast::channel::<Event>(64);
|
let (tx, _rx_keep) = broadcast::channel::<Event>(64);
|
||||||
worker.attach_working_event_tx(tx);
|
worker.attach_working_event_tx(tx);
|
||||||
|
|
||||||
worker.run_text("first").await.unwrap();
|
worker.run_text("first").await.unwrap();
|
||||||
let session_id = worker.session_id();
|
let session_id = worker.session_id();
|
||||||
|
let source_segment_id = worker.segment_id();
|
||||||
worker.compact(10_000).await.unwrap();
|
worker.compact(10_000).await.unwrap();
|
||||||
let compacted_segment_id = worker.segment_id();
|
let compacted_segment_id = worker.segment_id();
|
||||||
let metadata = worker
|
let metadata = worker
|
||||||
@@ -448,10 +750,158 @@ async fn compact_emits_session_start_carrying_summary_and_task_snapshot() {
|
|||||||
.any(|text| text.starts_with("[Session TaskStore snapshot]")),
|
.any(|text| text.starts_with("[Session TaskStore snapshot]")),
|
||||||
"task snapshot system message missing from {system_texts:?}"
|
"task snapshot system message missing from {system_texts:?}"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
worker.run_text("after compaction").await.unwrap();
|
||||||
|
let metrics = session_metrics::read_session_metrics(worker.store(), session_id).unwrap();
|
||||||
|
let starts = metrics
|
||||||
|
.iter()
|
||||||
|
.filter(|record| record.metric.name == "compact.start")
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(starts.len(), 1);
|
||||||
|
assert_eq!(starts[0].segment_id, source_segment_id);
|
||||||
|
assert_eq!(starts[0].metric.dimensions["mode"], "automatic");
|
||||||
|
assert_eq!(starts[0].metric.dimensions["trigger"], "request_threshold");
|
||||||
|
assert_eq!(
|
||||||
|
starts[0].metric.dimensions["threshold_policy"],
|
||||||
|
"request_threshold"
|
||||||
|
);
|
||||||
|
let correlation_id = starts[0]
|
||||||
|
.metric
|
||||||
|
.correlation_id
|
||||||
|
.as_deref()
|
||||||
|
.expect("compact start must carry a correlation id");
|
||||||
|
let finish = metrics
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.metric.name == "compact.finish")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(finish.segment_id, compacted_segment_id);
|
||||||
|
assert_eq!(finish.metric.dimensions["outcome"], "succeeded");
|
||||||
|
assert_eq!(finish.metric.value, Some(1.0));
|
||||||
|
assert_eq!(
|
||||||
|
finish.metric.correlation_id.as_deref(),
|
||||||
|
Some(correlation_id)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
finish.compacted_from.as_ref().unwrap().segment_id,
|
||||||
|
starts[0].segment_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let value = |name: &str| {
|
||||||
|
metrics
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.metric.name == name)
|
||||||
|
.and_then(|record| record.metric.value)
|
||||||
|
.unwrap() as u64
|
||||||
|
};
|
||||||
|
assert_eq!(value("compact.input_tokens"), 150);
|
||||||
|
assert_eq!(value("compact.cache_read_tokens"), 13);
|
||||||
|
assert_eq!(value("compact.cache_creation_tokens"), 7);
|
||||||
|
assert_eq!(value("compact.output_tokens"), 30);
|
||||||
|
assert_eq!(value("compact.requests"), 2);
|
||||||
|
assert!(value("compact.tool_calls") >= 1);
|
||||||
|
assert!(value("compact.turns") >= 2);
|
||||||
|
assert!(value("compact.duration_ms") <= u64::MAX);
|
||||||
|
let cost = metrics
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.metric.name == "compact.cost_usd")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cost.metric.value, None);
|
||||||
|
assert_eq!(cost.metric.dimensions["status"], "unavailable");
|
||||||
|
let post = metrics
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.metric.name == "compact.post_request")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(post.segment_id, compacted_segment_id);
|
||||||
|
assert_eq!(post.metric.correlation_id.as_deref(), Some(correlation_id));
|
||||||
|
assert_eq!(post.metric.dimensions["input_total_tokens"], "44");
|
||||||
|
assert_eq!(post.metric.dimensions["cache_read_tokens"], "4");
|
||||||
|
assert_eq!(post.metric.dimensions["cache_write_tokens"], "1");
|
||||||
|
assert_eq!(post.metric.dimensions["output_tokens"], "6");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pre_run_compact_success_broadcasts_start_and_done() {
|
async fn manual_compact_metrics_identify_manual_mode() {
|
||||||
|
let client = MockClient::new(vec![
|
||||||
|
single_text_events("seed response"),
|
||||||
|
write_summary_tool_use_events("summary", "replacement summary"),
|
||||||
|
single_text_events("done"),
|
||||||
|
]);
|
||||||
|
let mut worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await;
|
||||||
|
worker.run_text("seed input").await.unwrap();
|
||||||
|
let session_id = worker.session_id();
|
||||||
|
|
||||||
|
worker.manual_compact().await.unwrap();
|
||||||
|
|
||||||
|
let metrics = session_metrics::read_session_metrics(worker.store(), session_id).unwrap();
|
||||||
|
let start = metrics
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.metric.name == "compact.start")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(start.metric.dimensions["mode"], "manual");
|
||||||
|
assert_eq!(start.metric.dimensions["trigger"], "manual");
|
||||||
|
assert_eq!(start.metric.dimensions["threshold_policy"], "manual");
|
||||||
|
let finish = metrics
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.metric.name == "compact.finish")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(finish.metric.dimensions["outcome"], "succeeded");
|
||||||
|
assert_eq!(finish.metric.value, Some(1.0));
|
||||||
|
assert_eq!(finish.metric.correlation_id, start.metric.correlation_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn compact_failure_and_cancellation_emit_bounded_categories() {
|
||||||
|
let client = MockClient::new(vec![
|
||||||
|
single_text_events("seed response"),
|
||||||
|
single_text_events("missing summary"),
|
||||||
|
single_text_events("still missing summary"),
|
||||||
|
]);
|
||||||
|
let mut worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await;
|
||||||
|
worker.run_text("seed input").await.unwrap();
|
||||||
|
let session_id = worker.session_id();
|
||||||
|
let source_segment_id = worker.segment_id();
|
||||||
|
|
||||||
|
let error = worker.manual_compact().await.unwrap_err();
|
||||||
|
assert!(matches!(error, worker::WorkerError::CompactSummaryMissing));
|
||||||
|
let metrics = session_metrics::read_session_metrics(worker.store(), session_id).unwrap();
|
||||||
|
let failure = metrics
|
||||||
|
.iter()
|
||||||
|
.find(|record| {
|
||||||
|
record.metric.name == "compact.finish"
|
||||||
|
&& record.metric.dimensions["outcome"] == "failed"
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(failure.segment_id, source_segment_id);
|
||||||
|
assert_eq!(failure.metric.value, Some(1.0));
|
||||||
|
assert_eq!(
|
||||||
|
failure.metric.dimensions["failure_category"],
|
||||||
|
"summary_missing"
|
||||||
|
);
|
||||||
|
let encoded = serde_json::to_string(&failure.metric).unwrap();
|
||||||
|
assert!(!encoded.contains("missing summary"));
|
||||||
|
|
||||||
|
let (_cancel_tx, cancel_rx) = tokio::sync::watch::channel(true);
|
||||||
|
let error = worker
|
||||||
|
.manual_compact_with_cancel(cancel_rx)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, worker::WorkerError::CompactCancelled));
|
||||||
|
let metrics = session_metrics::read_session_metrics(worker.store(), session_id).unwrap();
|
||||||
|
let cancelled = metrics
|
||||||
|
.iter()
|
||||||
|
.filter(|record| {
|
||||||
|
record.metric.name == "compact.finish"
|
||||||
|
&& record.metric.dimensions["outcome"] == "cancelled"
|
||||||
|
})
|
||||||
|
.last()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(cancelled.segment_id, source_segment_id);
|
||||||
|
assert_eq!(cancelled.metric.value, Some(1.0));
|
||||||
|
assert_eq!(cancelled.metric.dimensions["failure_category"], "cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pre_run_compact_publishes_runtime_progress_phases() {
|
||||||
// Responses: (1) first run returns short text, (2) compact worker
|
// Responses: (1) first run returns short text, (2) compact worker
|
||||||
// emits write_summary then closes (two LLM calls inside the compact
|
// emits write_summary then closes (two LLM calls inside the compact
|
||||||
// worker: one for write_summary, one that the compact loop consumes
|
// worker: one for write_summary, one that the compact loop consumes
|
||||||
@@ -477,86 +927,62 @@ async fn pre_run_compact_success_broadcasts_start_and_done() {
|
|||||||
assert_ne!(worker.segment_id(), segment_before);
|
assert_ne!(worker.segment_id(), segment_before);
|
||||||
|
|
||||||
let events = drain(&mut rx);
|
let events = drain(&mut rx);
|
||||||
let kinds: Vec<&str> = events
|
let progress = events
|
||||||
.iter()
|
|
||||||
.map(|e| match e {
|
|
||||||
Event::CompactStart { .. } => "start",
|
|
||||||
Event::CompactDone { .. } => "done",
|
|
||||||
Event::CompactFailed { .. } => "failed",
|
|
||||||
_ => "other",
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
kinds.contains(&"start") && kinds.contains(&"done"),
|
|
||||||
"expected CompactStart + CompactDone in {kinds:?}"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!kinds.contains(&"failed"),
|
|
||||||
"unexpected CompactFailed in {kinds:?}"
|
|
||||||
);
|
|
||||||
let starts = events
|
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|event| match event {
|
.filter_map(|event| match event {
|
||||||
Event::CompactStart { lifecycle } => Some(lifecycle),
|
Event::CompactionProgress { compaction } => {
|
||||||
|
Some(compaction.as_ref().map(|item| item.phase))
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
starts.len(),
|
progress,
|
||||||
2,
|
vec![
|
||||||
"start and Internal Worker binding revisions"
|
Some(protocol::CompactionPhase::Preparing),
|
||||||
|
Some(protocol::CompactionPhase::Summarizing),
|
||||||
|
Some(protocol::CompactionPhase::Committing),
|
||||||
|
None,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
assert_eq!(starts[0].compaction_id, starts[1].compaction_id);
|
assert!(events.iter().all(|event| !matches!(
|
||||||
assert_eq!(starts[0].revision, 1);
|
event,
|
||||||
assert!(starts[0].internal_worker.is_none());
|
Event::CompactStart { .. } | Event::CompactDone { .. } | Event::CompactFailed { .. }
|
||||||
assert_eq!(starts[1].revision, 2);
|
)));
|
||||||
assert!(matches!(
|
|
||||||
starts[1].internal_worker.as_ref().map(|worker| &worker.kind),
|
|
||||||
Some(protocol::InternalWorkerKind::Service { kind }) if kind == "compaction"
|
|
||||||
));
|
|
||||||
assert!(events.iter().any(|event| matches!(
|
assert!(events.iter().any(|event| matches!(
|
||||||
event,
|
event,
|
||||||
Event::InternalWorker { worker, .. }
|
Event::InternalWorker { worker, .. }
|
||||||
if matches!(&worker.kind, protocol::InternalWorkerKind::Service { kind } if kind == "compaction")
|
if matches!(&worker.kind, protocol::InternalWorkerKind::Service { kind } if kind == "compaction")
|
||||||
)), "compactor activity must be projected through the parent stream");
|
)), "compactor activity must be projected through the parent stream");
|
||||||
let completed = events
|
|
||||||
.iter()
|
|
||||||
.find_map(|event| match event {
|
|
||||||
Event::CompactDone { lifecycle } => Some(lifecycle),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.expect("completed lifecycle");
|
|
||||||
assert_eq!(completed.compaction_id, starts[0].compaction_id);
|
|
||||||
assert_eq!(completed.revision, 3);
|
|
||||||
assert_eq!(completed.summary.as_deref(), Some("summary"));
|
|
||||||
assert_eq!(completed.state, protocol::CompactionLifecycleState::Done);
|
|
||||||
let done_index = events
|
|
||||||
.iter()
|
|
||||||
.position(|event| matches!(event, Event::CompactDone { .. }))
|
|
||||||
.expect("done event");
|
|
||||||
let removed_index = events
|
|
||||||
.iter()
|
|
||||||
.position(|event| matches!(event, Event::InternalWorkerRemoved { .. }))
|
|
||||||
.expect("terminal compactor session must be released");
|
|
||||||
assert!(
|
|
||||||
done_index < removed_index,
|
|
||||||
"terminal lifecycle precedes release fence"
|
|
||||||
);
|
|
||||||
|
|
||||||
// CompactDone carries the new Segment ID; the Session ID is unchanged.
|
let active_entries = worker
|
||||||
let new_id_in_event = events.iter().find_map(|e| match e {
|
.store()
|
||||||
Event::CompactDone { lifecycle } => lifecycle
|
.read_all(worker.session_id(), worker.segment_id())
|
||||||
.new_segment_id
|
.unwrap();
|
||||||
.as_deref()
|
assert!(!active_entries.iter().any(|entry| matches!(
|
||||||
.and_then(|value| uuid::Uuid::parse_str(value).ok()),
|
entry,
|
||||||
_ => None,
|
LogEntry::Extension { domain, .. } if domain == "yoi.compaction"
|
||||||
});
|
)));
|
||||||
assert!(new_id_in_event.is_some(), "CompactDone missing");
|
let metrics = session_metrics::read_session_metrics(worker.store(), session_before).unwrap();
|
||||||
assert_eq!(new_id_in_event.unwrap(), worker.segment_id());
|
let start = metrics
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.metric.name == "compact.start")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(start.segment_id, segment_before);
|
||||||
|
assert_eq!(start.metric.dimensions["mode"], "automatic");
|
||||||
|
assert_eq!(start.metric.dimensions["trigger"], "pre_run");
|
||||||
|
assert_eq!(start.metric.dimensions["threshold_policy"], "pre_run");
|
||||||
|
let finish = metrics
|
||||||
|
.iter()
|
||||||
|
.find(|record| record.metric.name == "compact.finish")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(finish.metric.dimensions["outcome"], "succeeded");
|
||||||
|
assert_eq!(finish.metric.value, Some(1.0));
|
||||||
|
assert_eq!(finish.metric.correlation_id, start.metric.correlation_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mid_turn_compact_success_broadcasts_start_and_done() {
|
async fn request_threshold_compact_publishes_runtime_progress() {
|
||||||
// Path: `do_compact_and_resume` via PreRequestAction::Yield.
|
// Path: `do_compact_and_resume` via PreRequestAction::Yield.
|
||||||
//
|
//
|
||||||
// Sequence of LLM calls the mock will serve:
|
// Sequence of LLM calls the mock will serve:
|
||||||
@@ -566,12 +992,16 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
|
|||||||
// [2] compact worker closes (its final "done" response).
|
// [2] compact worker closes (its final "done" response).
|
||||||
// [3] resume() after compact makes one more LLM call.
|
// [3] resume() after compact makes one more LLM call.
|
||||||
let client = MockClient::new(vec![
|
let client = MockClient::new(vec![
|
||||||
text_events_with_usage("a", 1000),
|
text_events_with_usage("a", 100_000),
|
||||||
write_summary_tool_use_events("call-1", "summary"),
|
write_summary_tool_use_events("call-1", "summary"),
|
||||||
single_text_events("done"),
|
single_text_events("done"),
|
||||||
single_text_events("b"),
|
text_events_with_usage("b", 50),
|
||||||
]);
|
]);
|
||||||
let mut worker = make_worker_with_manifest(MID_TURN_MANIFEST_TOML, client).await;
|
let manifest = MID_TURN_MANIFEST_TOML.replace(
|
||||||
|
"compact_request_threshold = 100",
|
||||||
|
"compact_request_threshold = 50000",
|
||||||
|
);
|
||||||
|
let mut worker = make_worker_with_manifest(&manifest, client).await;
|
||||||
|
|
||||||
let (tx, mut rx) = broadcast::channel::<Event>(64);
|
let (tx, mut rx) = broadcast::channel::<Event>(64);
|
||||||
worker.attach_working_event_tx(tx);
|
worker.attach_working_event_tx(tx);
|
||||||
@@ -585,36 +1015,62 @@ async fn mid_turn_compact_success_broadcasts_start_and_done() {
|
|||||||
worker.run_text("second").await.unwrap();
|
worker.run_text("second").await.unwrap();
|
||||||
|
|
||||||
let events = drain(&mut rx);
|
let events = drain(&mut rx);
|
||||||
let kinds: Vec<&str> = events
|
assert!(events.iter().any(|event| matches!(
|
||||||
|
event,
|
||||||
|
Event::CompactionProgress { compaction: Some(progress) }
|
||||||
|
if progress.phase == protocol::CompactionPhase::Committing
|
||||||
|
)));
|
||||||
|
assert!(
|
||||||
|
events
|
||||||
|
.iter()
|
||||||
|
.any(|event| matches!(event, Event::CompactionProgress { compaction: None }))
|
||||||
|
);
|
||||||
|
let metrics =
|
||||||
|
session_metrics::read_session_metrics(worker.store(), worker.session_id()).unwrap();
|
||||||
|
let start = metrics
|
||||||
.iter()
|
.iter()
|
||||||
.map(|e| match e {
|
.find(|record| record.metric.name == "compact.start")
|
||||||
Event::CompactStart { .. } => "start",
|
.unwrap();
|
||||||
Event::CompactDone { .. } => "done",
|
assert_eq!(start.metric.dimensions["trigger"], "request_threshold");
|
||||||
Event::CompactFailed { .. } => "failed",
|
assert_eq!(
|
||||||
_ => "other",
|
start.metric.dimensions["threshold_policy"],
|
||||||
})
|
"request_threshold"
|
||||||
.collect();
|
|
||||||
assert!(
|
|
||||||
kinds.contains(&"start") && kinds.contains(&"done"),
|
|
||||||
"expected CompactStart + CompactDone in {kinds:?}"
|
|
||||||
);
|
);
|
||||||
assert!(
|
let correlation_id = start.metric.correlation_id.as_deref().unwrap();
|
||||||
!kinds.contains(&"failed"),
|
let post = metrics
|
||||||
"unexpected CompactFailed in {kinds:?}"
|
.iter()
|
||||||
);
|
.find(|record| record.metric.name == "compact.post_request")
|
||||||
|
.unwrap();
|
||||||
let new_id_in_event = events.iter().find_map(|e| match e {
|
assert_eq!(post.metric.correlation_id.as_deref(), Some(correlation_id));
|
||||||
Event::CompactDone { lifecycle } => lifecycle
|
|
||||||
.new_segment_id
|
|
||||||
.as_deref()
|
|
||||||
.and_then(|value| uuid::Uuid::parse_str(value).ok()),
|
|
||||||
_ => None,
|
|
||||||
});
|
|
||||||
assert_eq!(new_id_in_event, Some(worker.segment_id()));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn pre_run_compact_failure_broadcasts_start_and_failed() {
|
async fn compacted_context_above_request_threshold_fails_before_provider_request() {
|
||||||
|
let client = MockClient::new(vec![
|
||||||
|
text_events_with_usage("seed", 1000),
|
||||||
|
write_summary_tool_use_events("call-1", "still too large after compaction"),
|
||||||
|
single_text_events("done"),
|
||||||
|
single_text_events("must not be requested"),
|
||||||
|
]);
|
||||||
|
let call_count = Arc::clone(&client.call_count);
|
||||||
|
let mut worker = make_worker_with_manifest(MID_TURN_MANIFEST_TOML, client).await;
|
||||||
|
worker.run_text("first").await.unwrap();
|
||||||
|
|
||||||
|
let error = worker
|
||||||
|
.run_text("second")
|
||||||
|
.await
|
||||||
|
.expect_err("unsafe compacted context must fail closed");
|
||||||
|
|
||||||
|
assert!(matches!(error, WorkerError::CompactThrash));
|
||||||
|
assert_eq!(
|
||||||
|
call_count.load(Ordering::SeqCst),
|
||||||
|
3,
|
||||||
|
"the provider must receive only the seed and compaction requests"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn pre_run_compact_failure_clears_runtime_progress() {
|
||||||
// Only the first run has a response. Compaction will run the
|
// Only the first run has a response. Compaction will run the
|
||||||
// compact worker which immediately exhausts the mock → failure.
|
// compact worker which immediately exhausts the mock → failure.
|
||||||
let client = MockClient::new(vec![single_text_events("hi")]);
|
let client = MockClient::new(vec![single_text_events("hi")]);
|
||||||
@@ -626,31 +1082,28 @@ async fn pre_run_compact_failure_broadcasts_start_and_failed() {
|
|||||||
worker.run_text("first").await.unwrap();
|
worker.run_text("first").await.unwrap();
|
||||||
let _ = drain(&mut rx);
|
let _ = drain(&mut rx);
|
||||||
|
|
||||||
// Best-effort: returns Ok(()) even on failure, but emits CompactFailed.
|
// Best-effort: returns Ok(()) even on failure and clears runtime progress.
|
||||||
worker.try_pre_run_compact().await;
|
worker.try_pre_run_compact().await;
|
||||||
|
|
||||||
let events = drain(&mut rx);
|
let events = drain(&mut rx);
|
||||||
let kinds: Vec<&str> = events
|
assert!(events.iter().any(|event| matches!(
|
||||||
.iter()
|
event,
|
||||||
.map(|e| match e {
|
Event::CompactionProgress { compaction: Some(progress) }
|
||||||
Event::CompactStart { .. } => "start",
|
if progress.phase == protocol::CompactionPhase::Preparing
|
||||||
Event::CompactDone { .. } => "done",
|
)));
|
||||||
Event::CompactFailed { .. } => "failed",
|
|
||||||
_ => "other",
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
assert!(
|
assert!(
|
||||||
kinds.contains(&"start") && kinds.contains(&"failed"),
|
events
|
||||||
"expected CompactStart + CompactFailed in {kinds:?}"
|
.iter()
|
||||||
);
|
.any(|event| matches!(event, Event::CompactionProgress { compaction: None }))
|
||||||
assert!(
|
|
||||||
!kinds.contains(&"done"),
|
|
||||||
"unexpected CompactDone in {kinds:?}"
|
|
||||||
);
|
);
|
||||||
|
assert!(events.iter().all(|event| !matches!(
|
||||||
|
event,
|
||||||
|
Event::CompactStart { .. } | Event::CompactDone { .. } | Event::CompactFailed { .. }
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
async fn manual_compact_cancel_clears_progress_before_returning_idle() {
|
||||||
let worker =
|
let worker =
|
||||||
make_worker_with_manifest(POST_RUN_MANIFEST_TOML, BlockingCompactClient::new()).await;
|
make_worker_with_manifest(POST_RUN_MANIFEST_TOML, BlockingCompactClient::new()).await;
|
||||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||||
@@ -682,7 +1135,7 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let compact = protocol::WorkerCommandEnvelope::for_snapshot(1, &handle.shared_state.snapshot());
|
let compact = protocol::WorkerCommandEnvelope::new(1);
|
||||||
handle
|
handle
|
||||||
.send(Method::Compact { command: compact })
|
.send(Method::Compact { command: compact })
|
||||||
.await
|
.await
|
||||||
@@ -693,13 +1146,15 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
|||||||
.await
|
.await
|
||||||
.expect("timeout waiting for compact start")
|
.expect("timeout waiting for compact start")
|
||||||
.expect("event"),
|
.expect("event"),
|
||||||
Event::CompactStart { .. }
|
Event::CompactionProgress {
|
||||||
|
compaction: Some(_)
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let cancel = protocol::WorkerCommandEnvelope::for_snapshot(2, &handle.shared_state.snapshot());
|
let cancel = protocol::WorkerCommandEnvelope::new(2);
|
||||||
handle
|
handle
|
||||||
.send(Method::Cancel { command: cancel })
|
.send(Method::Cancel { command: cancel })
|
||||||
.await
|
.await
|
||||||
@@ -712,9 +1167,7 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
|||||||
.expect("timeout waiting for compact cancellation")
|
.expect("timeout waiting for compact cancellation")
|
||||||
.expect("event")
|
.expect("event")
|
||||||
{
|
{
|
||||||
Event::CompactFailed { lifecycle }
|
Event::CompactionProgress { compaction: None } => {
|
||||||
if lifecycle.state == protocol::CompactionLifecycleState::Interrupted =>
|
|
||||||
{
|
|
||||||
saw_interrupted = true;
|
saw_interrupted = true;
|
||||||
}
|
}
|
||||||
Event::WorkerState { snapshot }
|
Event::WorkerState { snapshot }
|
||||||
@@ -730,7 +1183,7 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let compact = protocol::WorkerCommandEnvelope::for_snapshot(3, &handle.shared_state.snapshot());
|
let compact = protocol::WorkerCommandEnvelope::new(3);
|
||||||
handle
|
handle
|
||||||
.send(Method::Compact { command: compact })
|
.send(Method::Compact { command: compact })
|
||||||
.await
|
.await
|
||||||
@@ -741,13 +1194,14 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
|||||||
.await
|
.await
|
||||||
.expect("timeout waiting for second compact start")
|
.expect("timeout waiting for second compact start")
|
||||||
.expect("event"),
|
.expect("event"),
|
||||||
Event::CompactStart { .. }
|
Event::CompactionProgress {
|
||||||
|
compaction: Some(_)
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let shutdown =
|
let shutdown = protocol::WorkerCommandEnvelope::new(4);
|
||||||
protocol::WorkerCommandEnvelope::for_snapshot(4, &handle.shared_state.snapshot());
|
|
||||||
handle
|
handle
|
||||||
.send(Method::Shutdown { command: shutdown })
|
.send(Method::Shutdown { command: shutdown })
|
||||||
.await
|
.await
|
||||||
@@ -759,9 +1213,7 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
|||||||
.expect("timeout waiting for shutdown")
|
.expect("timeout waiting for shutdown")
|
||||||
.expect("event")
|
.expect("event")
|
||||||
{
|
{
|
||||||
Event::CompactFailed { lifecycle }
|
Event::CompactionProgress { compaction: None } => {
|
||||||
if lifecycle.state == protocol::CompactionLifecycleState::Interrupted =>
|
|
||||||
{
|
|
||||||
interrupted_before_shutdown = true;
|
interrupted_before_shutdown = true;
|
||||||
}
|
}
|
||||||
Event::Shutdown => {
|
Event::Shutdown => {
|
||||||
@@ -781,14 +1233,14 @@ async fn manual_compact_cancel_terminalizes_before_returning_idle() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn controller_compact_method_emits_start_and_done() {
|
async fn controller_compact_method_publishes_progress_and_clear() {
|
||||||
let client = MockClient::new(vec![
|
let client = MockClient::new(vec![
|
||||||
text_events_with_usage("hi", 1000),
|
text_events_with_usage("hi", 1000),
|
||||||
write_summary_tool_use_events("manual-summary", "manual compact summary"),
|
write_summary_tool_use_events("manual-summary", "manual compact summary"),
|
||||||
single_text_events("done"),
|
single_text_events("done"),
|
||||||
single_text_events("follow-up"),
|
single_text_events("follow-up"),
|
||||||
]);
|
]);
|
||||||
let worker = make_worker_with_manifest(POST_RUN_MANIFEST_TOML, client).await;
|
let worker = make_worker_with_manifest(MANUAL_ONLY_MANIFEST_TOML, client).await;
|
||||||
let runtime_tmp = tempfile::tempdir().unwrap();
|
let runtime_tmp = tempfile::tempdir().unwrap();
|
||||||
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
let bash_output_dir = runtime_tmp.path().join("bash-output");
|
||||||
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
let (handle, _shutdown) = WorkerController::spawn(worker, runtime_tmp.path(), &bash_output_dir)
|
||||||
@@ -816,7 +1268,7 @@ async fn controller_compact_method_emits_start_and_done() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let command = protocol::WorkerCommandEnvelope::for_snapshot(1, &handle.shared_state.snapshot());
|
let command = protocol::WorkerCommandEnvelope::new(1);
|
||||||
handle
|
handle
|
||||||
.send(Method::Compact { command })
|
.send(Method::Compact { command })
|
||||||
.await
|
.await
|
||||||
@@ -828,14 +1280,12 @@ async fn controller_compact_method_emits_start_and_done() {
|
|||||||
.expect("timeout waiting for compact events")
|
.expect("timeout waiting for compact events")
|
||||||
.expect("event")
|
.expect("event")
|
||||||
{
|
{
|
||||||
Event::CompactStart { .. } => saw_start = true,
|
Event::CompactionProgress {
|
||||||
Event::CompactDone { .. } => {
|
compaction: Some(_),
|
||||||
|
} => saw_start = true,
|
||||||
|
Event::CompactionProgress { compaction: None } => {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Event::CompactFailed { lifecycle } => panic!(
|
|
||||||
"manual compact failed: {}",
|
|
||||||
lifecycle.error.as_deref().unwrap_or("unknown error")
|
|
||||||
),
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -865,6 +1315,6 @@ async fn controller_compact_method_emits_start_and_done() {
|
|||||||
protocol::WorkerStatus::Idle,
|
protocol::WorkerStatus::Idle,
|
||||||
"successful manual compaction must release the execution fence"
|
"successful manual compaction must release the execution fence"
|
||||||
);
|
);
|
||||||
let command = protocol::WorkerCommandEnvelope::for_snapshot(2, &handle.shared_state.snapshot());
|
let command = protocol::WorkerCommandEnvelope::new(2);
|
||||||
let _ = handle.send(Method::Shutdown { command }).await;
|
let _ = handle.send(Method::Shutdown { command }).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,11 +27,8 @@ type TestStore = CombinedStore<FsStore, FsWorkerStore>;
|
|||||||
|
|
||||||
static NEXT_COMMAND_ID: AtomicU64 = AtomicU64::new(1);
|
static NEXT_COMMAND_ID: AtomicU64 = AtomicU64::new(1);
|
||||||
|
|
||||||
fn worker_command(handle: &WorkerHandle) -> protocol::WorkerCommandEnvelope {
|
fn worker_command(_handle: &WorkerHandle) -> protocol::WorkerCommandEnvelope {
|
||||||
protocol::WorkerCommandEnvelope::for_snapshot(
|
protocol::WorkerCommandEnvelope::new(NEXT_COMMAND_ID.fetch_add(1, Ordering::Relaxed))
|
||||||
NEXT_COMMAND_ID.fetch_add(1, Ordering::Relaxed),
|
|
||||||
&handle.shared_state.snapshot(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reconstruct a worker-history-like `Vec<Item>` from the live session
|
/// Reconstruct a worker-history-like `Vec<Item>` from the live session
|
||||||
@@ -2219,6 +2216,70 @@ async fn status_json_reflects_worker_name() {
|
|||||||
// Socket transport tests
|
// Socket transport tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shutdown_closes_method_admission_before_terminal_confirmation() {
|
||||||
|
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
||||||
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
|
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||||
|
let (handle, mut shutdown_rx) =
|
||||||
|
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
handle
|
||||||
|
.send(Method::Shutdown {
|
||||||
|
command: worker_command(&handle),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
result = handle.send(Method::ListRewindTargets) => {
|
||||||
|
if result.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = &mut shutdown_rx => {
|
||||||
|
result.expect("controller shutdown signal should remain open");
|
||||||
|
panic!("method admission remained open until terminal confirmation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("method admission did not close during shutdown");
|
||||||
|
shutdown_rx.await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shutdown_joins_socket_server_with_active_connection() {
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
|
let worker = make_worker(MockClient::new(simple_text_events())).await;
|
||||||
|
let runtime_base = tempfile::tempdir().unwrap();
|
||||||
|
let bash_output_dir = runtime_base.path().join("bash-output");
|
||||||
|
let (handle, shutdown_rx) =
|
||||||
|
WorkerController::spawn(worker, runtime_base.path(), &bash_output_dir)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let socket_path = handle.runtime_dir.socket_path();
|
||||||
|
let _connection = UnixStream::connect(&socket_path).await.unwrap();
|
||||||
|
|
||||||
|
handle
|
||||||
|
.send(Method::Shutdown {
|
||||||
|
command: worker_command(&handle),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(5), shutdown_rx)
|
||||||
|
.await
|
||||||
|
.expect("controller should join its socket tasks")
|
||||||
|
.expect("controller shutdown signal should remain open");
|
||||||
|
assert!(!socket_path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn socket_run_receives_events() {
|
async fn socket_run_receives_events() {
|
||||||
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
use protocol::stream::{JsonLineReader, JsonLineWriter};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async fn restore_reclaims_and_clears_legacy_process_children() {
|
|||||||
target: scope_root.path().to_path_buf(),
|
target: scope_root.path().to_path_buf(),
|
||||||
permission: "write".into(),
|
permission: "write".into(),
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
store.write(&metadata).unwrap();
|
store.write(&metadata).unwrap();
|
||||||
@@ -35,6 +36,7 @@ async fn restore_reclaims_and_clears_legacy_process_children() {
|
|||||||
target: scope_root.path().to_path_buf(),
|
target: scope_root.path().to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
};
|
};
|
||||||
let parent_scope = SharedScope::new(
|
let parent_scope = SharedScope::new(
|
||||||
Scope::from_config(&ScopeConfig {
|
Scope::from_config(&ScopeConfig {
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ async fn legacy_callback_cannot_register_process_subworker_authority() {
|
|||||||
target: scope_root.path().to_path_buf(),
|
target: scope_root.path().to_path_buf(),
|
||||||
permission: Permission::Write,
|
permission: Permission::Write,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
|
symlink_policy: Default::default(),
|
||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+443
-30
@@ -365,23 +365,23 @@ pub fn validate_repository_key(value: &str) -> Result<(), RepositoryKeyError> {
|
|||||||
///
|
///
|
||||||
/// Local paths remain distinct from network Git transports so callers cannot
|
/// Local paths remain distinct from network Git transports so callers cannot
|
||||||
/// accidentally treat an unmaterialized remote as a server-local filesystem path.
|
/// accidentally treat an unmaterialized remote as a server-local filesystem path.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RepositorySourceKind {
|
pub enum RepositorySourceKind {
|
||||||
LocalPath,
|
LocalPath,
|
||||||
File,
|
File,
|
||||||
Ssh,
|
Ssh,
|
||||||
Http,
|
|
||||||
Https,
|
Https,
|
||||||
/// A legacy value that could not be classified during migration. It remains
|
/// A legacy value that could not be classified during migration. It remains
|
||||||
/// inspectable but every provider operation must fail closed.
|
/// inspectable but every provider operation must fail closed. Historical
|
||||||
|
/// `http` wire values decode into this non-executable classification.
|
||||||
Invalid,
|
Invalid,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RepositorySourceKind {
|
impl RepositorySourceKind {
|
||||||
pub const fn is_remote(self) -> bool {
|
pub const fn is_remote(self) -> bool {
|
||||||
matches!(self, Self::Ssh | Self::Http | Self::Https)
|
matches!(self, Self::Ssh | Self::Https)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub const fn as_str(self) -> &'static str {
|
pub const fn as_str(self) -> &'static str {
|
||||||
@@ -389,7 +389,6 @@ impl RepositorySourceKind {
|
|||||||
Self::LocalPath => "local_path",
|
Self::LocalPath => "local_path",
|
||||||
Self::File => "file",
|
Self::File => "file",
|
||||||
Self::Ssh => "ssh",
|
Self::Ssh => "ssh",
|
||||||
Self::Http => "http",
|
|
||||||
Self::Https => "https",
|
Self::Https => "https",
|
||||||
Self::Invalid => "invalid",
|
Self::Invalid => "invalid",
|
||||||
}
|
}
|
||||||
@@ -400,14 +399,28 @@ impl RepositorySourceKind {
|
|||||||
"local_path" => Self::LocalPath,
|
"local_path" => Self::LocalPath,
|
||||||
"file" => Self::File,
|
"file" => Self::File,
|
||||||
"ssh" => Self::Ssh,
|
"ssh" => Self::Ssh,
|
||||||
"http" => Self::Http,
|
|
||||||
"https" => Self::Https,
|
"https" => Self::Https,
|
||||||
"invalid" => Self::Invalid,
|
"http" | "invalid" => Self::Invalid,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for RepositorySourceKind {
|
||||||
|
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let value = String::deserialize(deserializer)?;
|
||||||
|
Self::parse(&value).ok_or_else(|| {
|
||||||
|
serde::de::Error::unknown_variant(
|
||||||
|
&value,
|
||||||
|
&["local_path", "file", "ssh", "https", "invalid"],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Stable Repository source identity stored by Workspace authority.
|
/// Stable Repository source identity stored by Workspace authority.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
@@ -507,6 +520,25 @@ pub struct WorkspaceRepositoryRecord {
|
|||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initial Repository registration intent for Workspace creation.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct InitialRepositoryIntent {
|
||||||
|
pub repository_key: String,
|
||||||
|
pub uri: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub default_ref: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Request for atomically creating a Workspace and its initial Repository.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkspaceCreateRequest {
|
||||||
|
pub operation_key: String,
|
||||||
|
pub display_name: String,
|
||||||
|
pub repository: InitialRepositoryIntent,
|
||||||
|
}
|
||||||
|
|
||||||
/// Response returned after atomically creating a Workspace and its first Repository.
|
/// Response returned after atomically creating a Workspace and its first Repository.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
@@ -577,7 +609,7 @@ pub struct WorkspaceResponse {
|
|||||||
pub extension_points: WorkspaceExtensionPoints,
|
pub extension_points: WorkspaceExtensionPoints,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Workspace identity metadata exposed by the current settings resource.
|
/// Workspace display metadata exposed from the Server DB settings authority.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -607,6 +639,64 @@ pub struct WorkspaceMetadataMutationResponse {
|
|||||||
pub diagnostics: Vec<Diagnostic>,
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lifecycle state for a Workspace-scoped Ed25519 signing identity.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkspaceSigningIdentityState {
|
||||||
|
PendingProvisioning,
|
||||||
|
Active,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public metadata for a Workspace signing identity. Private material and its
|
||||||
|
/// storage reference are deliberately not part of this wire authority.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkspaceSigningIdentityPublic {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub key_id: String,
|
||||||
|
pub algorithm: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||||
|
pub public_key: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||||
|
pub public_key_fingerprint: Option<String>,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub revision: u64,
|
||||||
|
pub state: WorkspaceSigningIdentityState,
|
||||||
|
pub created_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||||
|
pub provisioned_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Copyable public trust bundle consumed by future Runtime enrollment work.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkspacePublicIdentityBundle {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub backend_url: String,
|
||||||
|
pub key_id: String,
|
||||||
|
pub algorithm: String,
|
||||||
|
pub public_key: String,
|
||||||
|
pub public_key_fingerprint: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkspaceSigningIdentityResponse {
|
||||||
|
pub identity: WorkspaceSigningIdentityPublic,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(optional))]
|
||||||
|
pub public_bundle: Option<WorkspacePublicIdentityBundle>,
|
||||||
|
}
|
||||||
|
|
||||||
pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128;
|
pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128;
|
||||||
pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128;
|
pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128;
|
||||||
pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256;
|
pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256;
|
||||||
@@ -1094,6 +1184,58 @@ pub struct RepositoryDetailResponse {
|
|||||||
pub source: String,
|
pub source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RepositorySshConnectionProbeRequest {
|
||||||
|
pub runtime_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RepositorySshHostKeyCandidate {
|
||||||
|
pub algorithm: String,
|
||||||
|
pub host_key: String,
|
||||||
|
pub fingerprint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RepositorySshConnectionTrustState {
|
||||||
|
Untrusted,
|
||||||
|
Verified,
|
||||||
|
Changed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RepositorySshConnectionProbeResponse {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub repository_key: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub hostname: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub trust_state: RepositorySshConnectionTrustState,
|
||||||
|
pub host_trust_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
|
pub expected_host_trust_revision: Option<u64>,
|
||||||
|
pub candidates: Vec<RepositorySshHostKeyCandidate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct ConfirmRepositorySshHostTrustRequest {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub host_key: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
|
pub expected_host_trust_revision: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -1138,8 +1280,7 @@ pub struct Diagnostic {
|
|||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum WorkingDirectoryMaterializerKind {
|
pub enum WorkingDirectoryMaterializerKind {
|
||||||
#[default]
|
#[default]
|
||||||
RuntimeGitCache,
|
RuntimeGitClone,
|
||||||
LocalGitWorktree,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -1522,6 +1663,71 @@ pub struct RuntimeSummary {
|
|||||||
pub diagnostics: Vec<Diagnostic>,
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum WorkspaceRuntimeBindingState {
|
||||||
|
Configured,
|
||||||
|
Verified,
|
||||||
|
Revoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeConnectionDisplayState {
|
||||||
|
Configured,
|
||||||
|
Verified,
|
||||||
|
Unavailable,
|
||||||
|
Revoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeVerificationOutcome {
|
||||||
|
Verified,
|
||||||
|
ChallengeIssued,
|
||||||
|
VerificationFailed,
|
||||||
|
ConnectivityFailed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RuntimeVerificationEvidenceSummary {
|
||||||
|
pub verified_at: Option<String>,
|
||||||
|
pub last_checked_at: String,
|
||||||
|
pub last_outcome: RuntimeVerificationOutcome,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub binding_revision: u64,
|
||||||
|
pub workspace_key_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub workspace_identity_revision: u64,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub workspace_trust_generation: u64,
|
||||||
|
pub runtime_public_key_fingerprint: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub runtime_identity_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkspaceRuntimeBindingSummary {
|
||||||
|
pub state: WorkspaceRuntimeBindingState,
|
||||||
|
pub connection_state: RuntimeConnectionDisplayState,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub revision: u64,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub workspace_key_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
|
pub workspace_key_generation: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub verification: Option<RuntimeVerificationEvidenceSummary>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -1531,6 +1737,8 @@ pub struct RuntimeManagementSummary {
|
|||||||
pub removable: bool,
|
pub removable: bool,
|
||||||
pub endpoint_configured: bool,
|
pub endpoint_configured: bool,
|
||||||
pub token_ref_configured: bool,
|
pub token_ref_configured: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub binding: Option<WorkspaceRuntimeBindingSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -1616,19 +1824,46 @@ pub struct RuntimeTrustKeyRevealResponse {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct PutRuntimeTrustKeyRequest {
|
pub struct RevokeRuntimeTrustKeyRequest {
|
||||||
pub public_key: String,
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
#[serde(default)]
|
pub expected_revision: u64,
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
|
||||||
pub expected_revision: Option<u64>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RevokeRuntimeTrustKeyRequest {
|
pub struct RemoveRuntimeRequest {
|
||||||
|
pub operation_id: String,
|
||||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
pub expected_revision: u64,
|
pub expected_binding_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeRemovalOperationState {
|
||||||
|
Pending,
|
||||||
|
CleanupPending,
|
||||||
|
Succeeded,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RuntimeRemovalOperationResponse {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime_id: String,
|
||||||
|
pub state: RuntimeRemovalOperationState,
|
||||||
|
pub binding_removed: bool,
|
||||||
|
pub runtime_registration_removed: Option<bool>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub failure_category: Option<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub completed_at: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -1653,12 +1888,33 @@ pub struct RuntimeTrustConflictResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RuntimePublicIdentityBundle {
|
||||||
|
pub identity_id: String,
|
||||||
|
pub public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct CreateRemoteRuntimeRequest {
|
pub struct CreateRemoteRuntimeRequest {
|
||||||
pub runtime_id: String,
|
pub public_bundle: RuntimePublicIdentityBundle,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub display_name: Option<String>,
|
||||||
|
pub endpoint: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
|
pub expected_revision: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct UpdateRemoteRuntimeRequest {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub endpoint: String,
|
pub endpoint: String,
|
||||||
pub token_ref: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -1690,6 +1946,10 @@ pub enum RuntimeConnectionTestFailureKind {
|
|||||||
pub struct RuntimeConnectionTestResponse {
|
pub struct RuntimeConnectionTestResponse {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub binding_revision: u64,
|
||||||
|
pub connection_state: RuntimeConnectionDisplayState,
|
||||||
|
pub verification: Option<RuntimeVerificationEvidenceSummary>,
|
||||||
pub checked_at: String,
|
pub checked_at: String,
|
||||||
pub status: RuntimeConnectionTestStatus,
|
pub status: RuntimeConnectionTestStatus,
|
||||||
pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
|
pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
|
||||||
@@ -2324,6 +2584,27 @@ pub struct CreateRepositorySshCredentialRequest {
|
|||||||
pub passphrase: Option<String>,
|
pub passphrase: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct GenerateRepositorySshCredentialRequest {
|
||||||
|
pub operation_id: String,
|
||||||
|
pub credential_id: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RepositorySshPublicKey {
|
||||||
|
pub credential_id: String,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub current_revision: u64,
|
||||||
|
pub public_key_algorithm: String,
|
||||||
|
pub public_key_fingerprint: String,
|
||||||
|
pub public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -2848,6 +3129,10 @@ pub fn catalog_typescript() -> String {
|
|||||||
WorkspaceMetadataSettingsResponse::decl(&config),
|
WorkspaceMetadataSettingsResponse::decl(&config),
|
||||||
UpdateWorkspaceMetadataRequest::decl(&config),
|
UpdateWorkspaceMetadataRequest::decl(&config),
|
||||||
WorkspaceMetadataMutationResponse::decl(&config),
|
WorkspaceMetadataMutationResponse::decl(&config),
|
||||||
|
WorkspaceSigningIdentityState::decl(&config),
|
||||||
|
WorkspaceSigningIdentityPublic::decl(&config),
|
||||||
|
WorkspacePublicIdentityBundle::decl(&config),
|
||||||
|
WorkspaceSigningIdentityResponse::decl(&config),
|
||||||
ProfileSettingsResponse::decl(&config),
|
ProfileSettingsResponse::decl(&config),
|
||||||
WorkspaceProfileSummary::decl(&config),
|
WorkspaceProfileSummary::decl(&config),
|
||||||
WorkspaceProfileSourceSummary::decl(&config),
|
WorkspaceProfileSourceSummary::decl(&config),
|
||||||
@@ -2862,12 +3147,22 @@ pub fn catalog_typescript() -> String {
|
|||||||
GitCommitSummary::decl(&config),
|
GitCommitSummary::decl(&config),
|
||||||
RepositoryListResponse::decl(&config),
|
RepositoryListResponse::decl(&config),
|
||||||
RepositoryDetailResponse::decl(&config),
|
RepositoryDetailResponse::decl(&config),
|
||||||
|
RepositorySshConnectionProbeRequest::decl(&config),
|
||||||
|
RepositorySshHostKeyCandidate::decl(&config),
|
||||||
|
RepositorySshConnectionTrustState::decl(&config),
|
||||||
|
RepositorySshConnectionProbeResponse::decl(&config),
|
||||||
|
ConfirmRepositorySshHostTrustRequest::decl(&config),
|
||||||
RepositoryLogResponse::decl(&config),
|
RepositoryLogResponse::decl(&config),
|
||||||
RuntimeSourceKind::decl(&config),
|
RuntimeSourceKind::decl(&config),
|
||||||
RuntimeSourceStatus::decl(&config),
|
RuntimeSourceStatus::decl(&config),
|
||||||
RuntimeIdentityAuthority::decl(&config),
|
RuntimeIdentityAuthority::decl(&config),
|
||||||
RuntimeSourceSummary::decl(&config),
|
RuntimeSourceSummary::decl(&config),
|
||||||
RuntimeSummary::decl(&config),
|
RuntimeSummary::decl(&config),
|
||||||
|
WorkspaceRuntimeBindingState::decl(&config),
|
||||||
|
RuntimeConnectionDisplayState::decl(&config),
|
||||||
|
RuntimeVerificationOutcome::decl(&config),
|
||||||
|
RuntimeVerificationEvidenceSummary::decl(&config),
|
||||||
|
WorkspaceRuntimeBindingSummary::decl(&config),
|
||||||
RuntimeManagementSummary::decl(&config),
|
RuntimeManagementSummary::decl(&config),
|
||||||
WorkspaceRuntimeResource::decl(&config),
|
WorkspaceRuntimeResource::decl(&config),
|
||||||
RuntimeTrustKeyStatus::decl(&config),
|
RuntimeTrustKeyStatus::decl(&config),
|
||||||
@@ -2876,10 +3171,15 @@ pub fn catalog_typescript() -> String {
|
|||||||
RuntimeTrustAuditEntry::decl(&config),
|
RuntimeTrustAuditEntry::decl(&config),
|
||||||
WorkspaceRuntimeDetail::decl(&config),
|
WorkspaceRuntimeDetail::decl(&config),
|
||||||
RuntimeTrustKeyRevealResponse::decl(&config),
|
RuntimeTrustKeyRevealResponse::decl(&config),
|
||||||
PutRuntimeTrustKeyRequest::decl(&config),
|
|
||||||
RevokeRuntimeTrustKeyRequest::decl(&config),
|
RevokeRuntimeTrustKeyRequest::decl(&config),
|
||||||
|
RemoveRuntimeRequest::decl(&config),
|
||||||
|
RuntimeRemovalOperationState::decl(&config),
|
||||||
|
RuntimeRemovalOperationResponse::decl(&config),
|
||||||
RuntimeTrustConflictKind::decl(&config),
|
RuntimeTrustConflictKind::decl(&config),
|
||||||
RuntimeTrustConflictResponse::decl(&config),
|
RuntimeTrustConflictResponse::decl(&config),
|
||||||
|
RuntimePublicIdentityBundle::decl(&config),
|
||||||
|
CreateRemoteRuntimeRequest::decl(&config),
|
||||||
|
UpdateRemoteRuntimeRequest::decl(&config),
|
||||||
RuntimeConnectionTestStatus::decl(&config),
|
RuntimeConnectionTestStatus::decl(&config),
|
||||||
RuntimeConnectionTestFailureKind::decl(&config),
|
RuntimeConnectionTestFailureKind::decl(&config),
|
||||||
RuntimeConnectionTestResponse::decl(&config),
|
RuntimeConnectionTestResponse::decl(&config),
|
||||||
@@ -2900,6 +3200,8 @@ pub fn repository_access_api_typescript() -> String {
|
|||||||
let declarations = [
|
let declarations = [
|
||||||
RepositorySshCredential::decl(&config),
|
RepositorySshCredential::decl(&config),
|
||||||
CreateRepositorySshCredentialRequest::decl(&config),
|
CreateRepositorySshCredentialRequest::decl(&config),
|
||||||
|
GenerateRepositorySshCredentialRequest::decl(&config),
|
||||||
|
RepositorySshPublicKey::decl(&config),
|
||||||
RotateRepositorySshCredentialRequest::decl(&config),
|
RotateRepositorySshCredentialRequest::decl(&config),
|
||||||
DeleteRepositorySshCredentialRequest::decl(&config),
|
DeleteRepositorySshCredentialRequest::decl(&config),
|
||||||
RepositorySshHostTrust::decl(&config),
|
RepositorySshHostTrust::decl(&config),
|
||||||
@@ -3180,6 +3482,27 @@ mod workdir_typescript_tests {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn historical_http_repository_source_kind_decodes_as_invalid_evidence() {
|
||||||
|
let source: RepositorySource = serde_json::from_value(serde_json::json!({
|
||||||
|
"kind": "http",
|
||||||
|
"uri": "http://git.example.test/team/project.git",
|
||||||
|
"revision": 1,
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(source.kind, RepositorySourceKind::Invalid);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(source).unwrap()["kind"],
|
||||||
|
serde_json::json!("invalid")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
RepositorySourceKind::parse("http"),
|
||||||
|
Some(RepositorySourceKind::Invalid)
|
||||||
|
);
|
||||||
|
assert!(!RepositorySourceKind::Invalid.is_remote());
|
||||||
|
}
|
||||||
|
|
||||||
fn skill_projection() -> SkillProjectionIdentity {
|
fn skill_projection() -> SkillProjectionIdentity {
|
||||||
SkillProjectionIdentity {
|
SkillProjectionIdentity {
|
||||||
config_revision: 42,
|
config_revision: 42,
|
||||||
@@ -3397,6 +3720,33 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remote_runtime_metadata_update_cannot_carry_public_key_authority() {
|
||||||
|
let request = UpdateRemoteRuntimeRequest {
|
||||||
|
display_name: Some("Runtime A".to_string()),
|
||||||
|
endpoint: "https://runtime.example.test".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(&request).unwrap(),
|
||||||
|
serde_json::json!({
|
||||||
|
"display_name": "Runtime A",
|
||||||
|
"endpoint": "https://runtime.example.test",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<UpdateRemoteRuntimeRequest>(serde_json::json!({
|
||||||
|
"display_name": "Runtime A",
|
||||||
|
"endpoint": "https://runtime.example.test",
|
||||||
|
"public_bundle": {
|
||||||
|
"identity_id": "runtime-a",
|
||||||
|
"public_key": "yoi-ed25519-pub:v1:not-accepted",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.is_err(),
|
||||||
|
"metadata updates must reject public key fields"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_launch_optional_omission_and_request_shape_are_stable() {
|
fn worker_launch_optional_omission_and_request_shape_are_stable() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -3568,6 +3918,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_create_request_has_one_closed_shared_wire_shape() {
|
||||||
|
let request = WorkspaceCreateRequest {
|
||||||
|
operation_key: "workspace-create-1".to_string(),
|
||||||
|
display_name: "Workspace".to_string(),
|
||||||
|
repository: InitialRepositoryIntent {
|
||||||
|
repository_key: "main".to_string(),
|
||||||
|
uri: "/srv/repositories/main".to_string(),
|
||||||
|
default_ref: Some("develop".to_string()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&request).unwrap();
|
||||||
|
assert_eq!(json["operation_key"], "workspace-create-1");
|
||||||
|
assert_eq!(json["repository"]["uri"], "/srv/repositories/main");
|
||||||
|
assert!(json.get("operation_id").is_none());
|
||||||
|
assert!(json["repository"].get("source").is_none());
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<WorkspaceCreateRequest>(serde_json::json!({
|
||||||
|
"operation_id": "workspace-create-1",
|
||||||
|
"display_name": "Workspace",
|
||||||
|
"repository": {
|
||||||
|
"repository_key": "main",
|
||||||
|
"source": "/srv/repositories/main"
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn workspace_and_repository_response_shapes_round_trip() {
|
fn workspace_and_repository_response_shapes_round_trip() {
|
||||||
let workspace = serde_json::json!({
|
let workspace = serde_json::json!({
|
||||||
@@ -3707,14 +4086,6 @@ mod tests {
|
|||||||
}))
|
}))
|
||||||
.is_err()
|
.is_err()
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
serde_json::from_value::<PutRuntimeTrustKeyRequest>(serde_json::json!({
|
|
||||||
"public_key": "key",
|
|
||||||
"expected_revision": 1,
|
|
||||||
"replace": true
|
|
||||||
}))
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
|
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
|
||||||
"expected_revision": 1,
|
"expected_revision": 1,
|
||||||
@@ -3729,6 +4100,9 @@ mod tests {
|
|||||||
let compatible = serde_json::json!({
|
let compatible = serde_json::json!({
|
||||||
"workspace_id": "workspace-test",
|
"workspace_id": "workspace-test",
|
||||||
"runtime_id": "runtime-test",
|
"runtime_id": "runtime-test",
|
||||||
|
"binding_revision": 3,
|
||||||
|
"connection_state": "verified",
|
||||||
|
"verification": null,
|
||||||
"checked_at": "2026-09-01T12:00:00Z",
|
"checked_at": "2026-09-01T12:00:00Z",
|
||||||
"status": "compatible",
|
"status": "compatible",
|
||||||
"failure_kind": null,
|
"failure_kind": null,
|
||||||
@@ -3877,6 +4251,45 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_signing_identity_wire_contract_omits_private_and_pending_fields() {
|
||||||
|
let response = WorkspaceSigningIdentityResponse {
|
||||||
|
identity: WorkspaceSigningIdentityPublic {
|
||||||
|
workspace_id: "workspace-test".to_string(),
|
||||||
|
key_id: "WK-test".to_string(),
|
||||||
|
algorithm: "ed25519".to_string(),
|
||||||
|
public_key: None,
|
||||||
|
public_key_fingerprint: None,
|
||||||
|
revision: 1,
|
||||||
|
state: WorkspaceSigningIdentityState::PendingProvisioning,
|
||||||
|
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
provisioned_at: None,
|
||||||
|
},
|
||||||
|
public_bundle: None,
|
||||||
|
};
|
||||||
|
let encoded = serde_json::to_value(&response).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
encoded,
|
||||||
|
serde_json::json!({
|
||||||
|
"identity": {
|
||||||
|
"workspace_id": "workspace-test",
|
||||||
|
"key_id": "WK-test",
|
||||||
|
"algorithm": "ed25519",
|
||||||
|
"revision": 1,
|
||||||
|
"state": "pending_provisioning",
|
||||||
|
"created_at": "2026-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<WorkspaceSigningIdentityResponse>(serde_json::json!({
|
||||||
|
"identity": encoded["identity"].clone(),
|
||||||
|
"private_material_ref": "must-not-cross-the-wire"
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
|
fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
|
||||||
WorkspaceWorkerDiscoveryItem {
|
WorkspaceWorkerDiscoveryItem {
|
||||||
subject: WorkspaceWorkerSubject::RuntimeWorker {
|
subject: WorkspaceWorkerSubject::RuntimeWorker {
|
||||||
@@ -4302,7 +4715,7 @@ mod tests {
|
|||||||
current_ref: None,
|
current_ref: None,
|
||||||
current_tree: None,
|
current_tree: None,
|
||||||
observed_at_epoch_seconds: None,
|
observed_at_epoch_seconds: None,
|
||||||
materializer_kind: WorkingDirectoryMaterializerKind::RuntimeGitCache,
|
materializer_kind: WorkingDirectoryMaterializerKind::RuntimeGitClone,
|
||||||
cleanup_target: None,
|
cleanup_target: None,
|
||||||
status: WorkingDirectoryStatusKind::Active,
|
status: WorkingDirectoryStatusKind::Active,
|
||||||
cleanliness: None,
|
cleanliness: None,
|
||||||
@@ -4339,7 +4752,7 @@ mod tests {
|
|||||||
"items": [{
|
"items": [{
|
||||||
"working_directory_id": "workdir-1",
|
"working_directory_id": "workdir-1",
|
||||||
"repository_key": "main",
|
"repository_key": "main",
|
||||||
"materializer_kind": "runtime_git_cache",
|
"materializer_kind": "runtime_git_clone",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"occupied_by": {
|
"occupied_by": {
|
||||||
"runtime_worker_id": "worker-1",
|
"runtime_worker_id": "worker-1",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ memory.workspace = true
|
|||||||
merge-request.workspace = true
|
merge-request.workspace = true
|
||||||
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
|
||||||
tower.workspace = true
|
tower.workspace = true
|
||||||
tokio-tungstenite.workspace = true
|
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
|
||||||
worker.workspace = true
|
worker.workspace = true
|
||||||
workspace-api.workspace = true
|
workspace-api.workspace = true
|
||||||
workdir = { workspace = true, features = ["http-client"] }
|
workdir = { workspace = true, features = ["http-client"] }
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ use std::{fs, io};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use crate::identity::WorkspaceIdentity;
|
|
||||||
use crate::server::{AuthConfig, ServerConfig};
|
use crate::server::{AuthConfig, ServerConfig};
|
||||||
|
use crate::store::WorkspaceRecord;
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
|
pub const SERVER_HOST_CONFIG_FILE_NAME: &str = "server.toml";
|
||||||
@@ -100,15 +100,15 @@ impl ServerHostConfigFile {
|
|||||||
impl ResolvedWorkspaceBackendConfig {
|
impl ResolvedWorkspaceBackendConfig {
|
||||||
pub fn local_dev(
|
pub fn local_dev(
|
||||||
workspace_root: impl AsRef<Path>,
|
workspace_root: impl AsRef<Path>,
|
||||||
identity: WorkspaceIdentity,
|
workspace: WorkspaceRecord,
|
||||||
host_config: &ServerHostConfigFile,
|
host_config: &ServerHostConfigFile,
|
||||||
) -> Result<Self> {
|
) -> Result<Self> {
|
||||||
let workspace_root = workspace_root.as_ref();
|
let workspace_root = workspace_root.as_ref();
|
||||||
let data_root = ServerConfig::default_workspace_backend_data_root(&identity.workspace_id);
|
let data_root = ServerConfig::default_workspace_backend_data_root(&workspace.workspace_id);
|
||||||
let database_path = ServerConfig::default_server_database_path();
|
let database_path = ServerConfig::default_server_database_path();
|
||||||
let (browser_public_url, browser_rp_id) =
|
let (browser_public_url, browser_rp_id) =
|
||||||
resolve_browser_public_url(&host_config.browser.public_url)?;
|
resolve_browser_public_url(&host_config.browser.public_url)?;
|
||||||
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), identity);
|
let mut server = ServerConfig::local_dev(workspace_root.to_path_buf(), workspace);
|
||||||
server.database_path = database_path.clone();
|
server.database_path = database_path.clone();
|
||||||
server.embedded_runtime_store_root = data_root.join("embedded-runtime");
|
server.embedded_runtime_store_root = data_root.join("embedded-runtime");
|
||||||
server.max_records = DEFAULT_MAX_RECORDS;
|
server.max_records = DEFAULT_MAX_RECORDS;
|
||||||
@@ -185,11 +185,14 @@ fn resolve_browser_public_url(value: &str) -> Result<(String, String)> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn identity() -> WorkspaceIdentity {
|
fn workspace() -> WorkspaceRecord {
|
||||||
WorkspaceIdentity {
|
WorkspaceRecord {
|
||||||
workspace_id: "018f6a2c-1111-7000-8000-000000000001".to_string(),
|
workspace_id: "018f6a2c-1111-7000-8000-000000000001".to_string(),
|
||||||
|
owner_account_id: "018f6a2c-1111-7000-8000-000000000002".to_string(),
|
||||||
created_at: "2026-01-01T00:00:00Z".to_string(),
|
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
updated_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
display_name: "Workspace".to_string(),
|
display_name: "Workspace".to_string(),
|
||||||
|
state: "active".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +200,7 @@ mod tests {
|
|||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
ResolvedWorkspaceBackendConfig::local_dev(
|
ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
dir.path(),
|
dir.path(),
|
||||||
identity(),
|
workspace(),
|
||||||
&ServerHostConfigFile::default(),
|
&ServerHostConfigFile::default(),
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -250,7 +253,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
let resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
tempfile::tempdir().unwrap().path(),
|
tempfile::tempdir().unwrap().path(),
|
||||||
identity(),
|
workspace(),
|
||||||
&host_config,
|
&host_config,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -280,7 +283,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let result = ResolvedWorkspaceBackendConfig::local_dev(
|
let result = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
tempfile::tempdir().unwrap().path(),
|
tempfile::tempdir().unwrap().path(),
|
||||||
identity(),
|
workspace(),
|
||||||
&host_config,
|
&host_config,
|
||||||
);
|
);
|
||||||
let error = match result {
|
let error = match result {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,355 +0,0 @@
|
|||||||
use std::fs::{self, OpenOptions};
|
|
||||||
use std::io::{ErrorKind, Write};
|
|
||||||
use std::path::{Path, PathBuf};
|
|
||||||
|
|
||||||
use chrono::{SecondsFormat, Utc};
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::{Error, Result};
|
|
||||||
|
|
||||||
pub const WORKSPACE_IDENTITY_RELATIVE_PATH: &str = ".yoi/workspace.toml";
|
|
||||||
|
|
||||||
/// Stable local Workspace identity persisted as a tracked, safe project record.
|
|
||||||
///
|
|
||||||
/// The v0 TOML schema contains identity metadata plus optional tracked project
|
|
||||||
/// policy tables such as `[ticket]`. Runtime/local-only settings remain rejected
|
|
||||||
/// here because this loader cannot safely round-trip future local runtime settings
|
|
||||||
/// without risking accidental path or secret persistence.
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
||||||
pub struct WorkspaceIdentity {
|
|
||||||
pub workspace_id: String,
|
|
||||||
pub created_at: String,
|
|
||||||
pub display_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct WorkspaceIdentityFile {
|
|
||||||
workspace_id: String,
|
|
||||||
created_at: String,
|
|
||||||
display_name: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
ticket: Option<toml::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl WorkspaceIdentity {
|
|
||||||
pub fn load_or_init(workspace_root: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
Self::load_or_init_with_clock(workspace_root.as_ref(), || {
|
|
||||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn load_required(workspace_root: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
let path = Self::path(workspace_root.as_ref());
|
|
||||||
match fs::read_to_string(&path) {
|
|
||||||
Ok(raw) => Self::parse_str(&raw, &path),
|
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
|
||||||
Err(Error::WorkspaceIdentity(format!(
|
|
||||||
"workspace identity is missing at {}; register the Workspace through the Server before using repository-local client routing",
|
|
||||||
workspace_root.as_ref().display()
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
Err(error) => Err(Error::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn path(workspace_root: impl AsRef<Path>) -> PathBuf {
|
|
||||||
workspace_root
|
|
||||||
.as_ref()
|
|
||||||
.join(WORKSPACE_IDENTITY_RELATIVE_PATH)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn parse_str(raw: &str, path: impl AsRef<Path>) -> Result<Self> {
|
|
||||||
let path = path.as_ref();
|
|
||||||
let parsed: WorkspaceIdentityFile = toml::from_str(raw).map_err(|error| {
|
|
||||||
workspace_identity_error(path, format!("failed to parse TOML: {error}"))
|
|
||||||
})?;
|
|
||||||
Self::from_file(parsed, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn load_or_init_with_clock(
|
|
||||||
workspace_root: &Path,
|
|
||||||
now_utc_rfc3339: impl FnOnce() -> String,
|
|
||||||
) -> Result<Self> {
|
|
||||||
let path = Self::path(workspace_root);
|
|
||||||
match fs::read_to_string(&path) {
|
|
||||||
Ok(raw) => Self::parse_str(&raw, &path),
|
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => {
|
|
||||||
Self::init(workspace_root, &path, now_utc_rfc3339())
|
|
||||||
}
|
|
||||||
Err(error) => Err(Error::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn init(workspace_root: &Path, path: &Path, created_at: String) -> Result<Self> {
|
|
||||||
validate_created_at(&created_at, path)?;
|
|
||||||
let display_name = workspace_display_name_from_root(workspace_root, path)?;
|
|
||||||
let workspace_id = Uuid::now_v7().to_string();
|
|
||||||
let identity = Self {
|
|
||||||
workspace_id,
|
|
||||||
created_at,
|
|
||||||
display_name,
|
|
||||||
};
|
|
||||||
identity.write_new_or_read_existing(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn from_file(parsed: WorkspaceIdentityFile, path: &Path) -> Result<Self> {
|
|
||||||
let workspace_id = validate_workspace_id(&parsed.workspace_id, path)?;
|
|
||||||
validate_created_at(&parsed.created_at, path)?;
|
|
||||||
validate_display_name(&parsed.display_name, path)?;
|
|
||||||
Ok(Self {
|
|
||||||
workspace_id,
|
|
||||||
created_at: parsed.created_at,
|
|
||||||
display_name: parsed.display_name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_new_or_read_existing(&self, path: &Path) -> Result<Self> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let raw = toml::to_string_pretty(&WorkspaceIdentityFile {
|
|
||||||
workspace_id: self.workspace_id.clone(),
|
|
||||||
created_at: self.created_at.clone(),
|
|
||||||
display_name: self.display_name.clone(),
|
|
||||||
ticket: None,
|
|
||||||
})
|
|
||||||
.map_err(|error| {
|
|
||||||
workspace_identity_error(path, format!("failed to encode TOML: {error}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
match OpenOptions::new().write(true).create_new(true).open(path) {
|
|
||||||
Ok(mut file) => {
|
|
||||||
file.write_all(raw.as_bytes())?;
|
|
||||||
file.sync_all()?;
|
|
||||||
Ok(self.clone())
|
|
||||||
}
|
|
||||||
Err(error) if error.kind() == ErrorKind::AlreadyExists => {
|
|
||||||
let raw = fs::read_to_string(path)?;
|
|
||||||
Self::parse_str(&raw, path)
|
|
||||||
}
|
|
||||||
Err(error) => Err(Error::Io(error)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_workspace_id(value: &str, path: &Path) -> Result<String> {
|
|
||||||
let uuid = Uuid::parse_str(value).map_err(|error| {
|
|
||||||
workspace_identity_error(path, format!("workspace_id is not a UUID: {error}"))
|
|
||||||
})?;
|
|
||||||
if uuid.get_version_num() != 7 {
|
|
||||||
return Err(workspace_identity_error(
|
|
||||||
path,
|
|
||||||
"workspace_id must be a UUIDv7 canonical string".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
let canonical = uuid.to_string();
|
|
||||||
if value != canonical {
|
|
||||||
return Err(workspace_identity_error(
|
|
||||||
path,
|
|
||||||
"workspace_id must use lowercase hyphenated UUID canonical form".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(canonical)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_created_at(value: &str, path: &Path) -> Result<()> {
|
|
||||||
let parsed = chrono::DateTime::parse_from_rfc3339(value).map_err(|error| {
|
|
||||||
workspace_identity_error(path, format!("created_at is not RFC3339: {error}"))
|
|
||||||
})?;
|
|
||||||
if parsed.offset().local_minus_utc() != 0 || !value.ends_with('Z') {
|
|
||||||
return Err(workspace_identity_error(
|
|
||||||
path,
|
|
||||||
"created_at must be a UTC RFC3339 timestamp ending in Z".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_display_name(value: &str, path: &Path) -> Result<()> {
|
|
||||||
if value.trim().is_empty() {
|
|
||||||
return Err(workspace_identity_error(
|
|
||||||
path,
|
|
||||||
"display_name must not be empty".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if value.contains('\0') || value.chars().any(|ch| ch.is_control()) {
|
|
||||||
return Err(workspace_identity_error(
|
|
||||||
path,
|
|
||||||
"display_name must not contain control characters".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn workspace_display_name_from_root(workspace_root: &Path, path: &Path) -> Result<String> {
|
|
||||||
let display_name = workspace_root
|
|
||||||
.file_name()
|
|
||||||
.and_then(|name| name.to_str())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
workspace_identity_error(
|
|
||||||
path,
|
|
||||||
"workspace root must have a UTF-8 final path component".to_string(),
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
.to_string();
|
|
||||||
validate_display_name(&display_name, path)?;
|
|
||||||
Ok(display_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn workspace_identity_error(path: &Path, message: String) -> Error {
|
|
||||||
Error::WorkspaceIdentity(format!("{}: {message}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
const FIXED_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001";
|
|
||||||
const FIXED_CREATED_AT: &str = "2026-06-23T06:43:28Z";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn load_required_rejects_uninitialized_workspace_without_creating_identity() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let workspace_root = temp.path().join("uninitialized-workspace");
|
|
||||||
fs::create_dir_all(&workspace_root).unwrap();
|
|
||||||
|
|
||||||
let error = WorkspaceIdentity::load_required(&workspace_root).unwrap_err();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
error.to_string().contains("workspace identity is missing"),
|
|
||||||
"unexpected error: {error}"
|
|
||||||
);
|
|
||||||
assert!(!WorkspaceIdentity::path(&workspace_root).exists());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_identity_file_is_created_with_safe_fields() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let workspace_root = temp.path().join("example-workspace");
|
|
||||||
fs::create_dir_all(&workspace_root).unwrap();
|
|
||||||
|
|
||||||
let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
|
||||||
FIXED_CREATED_AT.to_string()
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(identity.display_name, "example-workspace");
|
|
||||||
assert_eq!(identity.created_at, FIXED_CREATED_AT);
|
|
||||||
validate_workspace_id(
|
|
||||||
&identity.workspace_id,
|
|
||||||
&WorkspaceIdentity::path(&workspace_root),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap();
|
|
||||||
assert!(raw.contains("workspace_id"));
|
|
||||||
assert!(raw.contains("display_name"));
|
|
||||||
assert!(raw.contains("created_at"));
|
|
||||||
assert!(!raw.contains(&workspace_root.to_string_lossy().to_string()));
|
|
||||||
|
|
||||||
let reloaded = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
|
||||||
"2026-06-24T00:00:00Z".to_string()
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(reloaded, identity);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn existing_identity_file_is_stable() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let workspace_root = temp.path().join("moved-workspace");
|
|
||||||
let yoi_dir = workspace_root.join(".yoi");
|
|
||||||
fs::create_dir_all(&yoi_dir).unwrap();
|
|
||||||
let path = yoi_dir.join("workspace.toml");
|
|
||||||
let raw = format!(
|
|
||||||
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\n"
|
|
||||||
);
|
|
||||||
fs::write(&path, &raw).unwrap();
|
|
||||||
|
|
||||||
let identity = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
|
||||||
"2026-06-24T00:00:00Z".to_string()
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert_eq!(identity.workspace_id, FIXED_WORKSPACE_ID);
|
|
||||||
assert_eq!(identity.created_at, FIXED_CREATED_AT);
|
|
||||||
assert_eq!(identity.display_name, "Stable Project");
|
|
||||||
assert_eq!(fs::read_to_string(path).unwrap(), raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn create_new_race_returns_existing_persisted_identity() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let path = temp.path().join(".yoi/workspace.toml");
|
|
||||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
|
||||||
let persisted_raw = format!(
|
|
||||||
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Persisted Project\"\n"
|
|
||||||
);
|
|
||||||
fs::write(&path, &persisted_raw).unwrap();
|
|
||||||
let generated = WorkspaceIdentity {
|
|
||||||
workspace_id: "0192f0e8-4d84-7d6e-b000-000000000002".to_string(),
|
|
||||||
created_at: "2026-06-24T00:00:00Z".to_string(),
|
|
||||||
display_name: "Generated Project".to_string(),
|
|
||||||
};
|
|
||||||
|
|
||||||
let returned = generated.write_new_or_read_existing(&path).unwrap();
|
|
||||||
|
|
||||||
assert_eq!(returned.workspace_id, FIXED_WORKSPACE_ID);
|
|
||||||
assert_eq!(returned.created_at, FIXED_CREATED_AT);
|
|
||||||
assert_eq!(returned.display_name, "Persisted Project");
|
|
||||||
assert_eq!(fs::read_to_string(path).unwrap(), persisted_raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn invalid_identity_file_fails_closed_without_rewriting() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let workspace_root = temp.path().join("invalid-workspace");
|
|
||||||
let yoi_dir = workspace_root.join(".yoi");
|
|
||||||
fs::create_dir_all(&yoi_dir).unwrap();
|
|
||||||
let path = yoi_dir.join("workspace.toml");
|
|
||||||
let raw = "workspace_id = \"not-a-uuid\"\ncreated_at = \"2026-06-23T06:43:28Z\"\ndisplay_name = \"Invalid\"\n";
|
|
||||||
fs::write(&path, raw).unwrap();
|
|
||||||
|
|
||||||
let error = WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
|
||||||
FIXED_CREATED_AT.to_string()
|
|
||||||
})
|
|
||||||
.unwrap_err();
|
|
||||||
|
|
||||||
assert!(error.to_string().contains("workspace_id is not a UUID"));
|
|
||||||
assert_eq!(fs::read_to_string(path).unwrap(), raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generated_identity_does_not_leak_parent_paths() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let secret_parent = temp.path().join("user-secret-parent");
|
|
||||||
let workspace_root = secret_parent.join("public-project-name");
|
|
||||||
fs::create_dir_all(&workspace_root).unwrap();
|
|
||||||
|
|
||||||
WorkspaceIdentity::load_or_init_with_clock(&workspace_root, || {
|
|
||||||
FIXED_CREATED_AT.to_string()
|
|
||||||
})
|
|
||||||
.unwrap();
|
|
||||||
let raw = fs::read_to_string(WorkspaceIdentity::path(&workspace_root)).unwrap();
|
|
||||||
|
|
||||||
assert!(raw.contains("public-project-name"));
|
|
||||||
assert!(!raw.contains(&secret_parent.to_string_lossy().to_string()));
|
|
||||||
assert!(!raw.contains("user-secret-parent"));
|
|
||||||
assert!(!raw.contains("/"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn unknown_fields_are_rejected() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let path = temp.path().join("workspace.toml");
|
|
||||||
let raw = format!(
|
|
||||||
"workspace_id = \"{FIXED_WORKSPACE_ID}\"\ncreated_at = \"{FIXED_CREATED_AT}\"\ndisplay_name = \"Stable Project\"\nlocal_root = \"/tmp/secret\"\n"
|
|
||||||
);
|
|
||||||
|
|
||||||
let error = WorkspaceIdentity::parse_str(&raw, &path).unwrap_err();
|
|
||||||
|
|
||||||
assert!(error.to_string().contains("unknown field"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -441,13 +441,49 @@ CREATE TABLE workspace_runtime_bindings (
|
|||||||
public_key TEXT NOT NULL,
|
public_key TEXT NOT NULL,
|
||||||
public_key_fingerprint TEXT NOT NULL,
|
public_key_fingerprint TEXT NOT NULL,
|
||||||
binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0),
|
binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0),
|
||||||
|
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
|
||||||
|
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
|
||||||
|
workspace_key_id TEXT,
|
||||||
|
workspace_key_generation INTEGER CHECK (workspace_key_generation > 0),
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
revoked_at TEXT,
|
revoked_at TEXT,
|
||||||
PRIMARY KEY (workspace_id, runtime_id),
|
PRIMARY KEY (workspace_id, runtime_id),
|
||||||
UNIQUE (workspace_id, public_key_fingerprint),
|
UNIQUE (workspace_id, public_key_fingerprint),
|
||||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
|
||||||
|
CHECK (
|
||||||
|
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL)
|
||||||
|
OR
|
||||||
|
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL)
|
||||||
|
),
|
||||||
|
CHECK (
|
||||||
|
(state = 'revoked' AND revoked_at IS NOT NULL)
|
||||||
|
OR
|
||||||
|
(state != 'revoked' AND revoked_at IS NULL)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE workspace_runtime_verifications (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
runtime_id TEXT NOT NULL,
|
||||||
|
binding_revision INTEGER NOT NULL CHECK(binding_revision > 0),
|
||||||
|
workspace_key_id TEXT NOT NULL,
|
||||||
|
workspace_identity_revision INTEGER NOT NULL CHECK(workspace_identity_revision > 0),
|
||||||
|
workspace_trust_generation INTEGER NOT NULL CHECK(workspace_trust_generation > 0),
|
||||||
|
runtime_public_key_fingerprint TEXT NOT NULL,
|
||||||
|
runtime_identity_revision INTEGER NOT NULL CHECK(runtime_identity_revision > 0),
|
||||||
|
challenge_id TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
|
||||||
|
last_outcome TEXT NOT NULL,
|
||||||
|
verified_at TEXT,
|
||||||
|
checked_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(workspace_id, runtime_id),
|
||||||
|
FOREIGN KEY(workspace_id, runtime_id)
|
||||||
|
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE,
|
||||||
|
CHECK((state = 'verified' AND verified_at IS NOT NULL)
|
||||||
|
OR (state != 'verified' AND verified_at IS NULL))
|
||||||
|
);
|
||||||
|
CREATE INDEX workspace_runtime_verifications_state_idx
|
||||||
|
ON workspace_runtime_verifications(workspace_id, state, checked_at DESC);
|
||||||
CREATE TABLE workspace_runtime_binding_audit (
|
CREATE TABLE workspace_runtime_binding_audit (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
runtime_id TEXT NOT NULL,
|
runtime_id TEXT NOT NULL,
|
||||||
@@ -588,10 +624,46 @@ CREATE TABLE workdir_create_operations (
|
|||||||
state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')),
|
state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')),
|
||||||
failure TEXT,
|
failure TEXT,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_revision INTEGER, source_fingerprint TEXT, credential_id TEXT, credential_revision INTEGER, host_trust_id TEXT, host_trust_revision INTEGER, repository_access_mode TEXT, cache_generation INTEGER NOT NULL DEFAULT 0,
|
updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_revision INTEGER, source_fingerprint TEXT, credential_id TEXT, credential_revision INTEGER, host_trust_id TEXT, host_trust_revision INTEGER, repository_access_mode TEXT,
|
||||||
PRIMARY KEY (workspace_id, operation_id),
|
PRIMARY KEY (workspace_id, operation_id),
|
||||||
UNIQUE (workspace_id, working_directory_id)
|
UNIQUE (workspace_id, working_directory_id)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE workdir_create_credential_candidates (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
operation_id TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL CHECK (ordinal >= 0 AND ordinal < 2),
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('primary', 'workspace_default_fallback')),
|
||||||
|
credential_id TEXT NOT NULL CHECK (length(credential_id) BETWEEN 1 AND 128),
|
||||||
|
credential_revision INTEGER NOT NULL CHECK (credential_revision > 0),
|
||||||
|
PRIMARY KEY (workspace_id, operation_id, ordinal),
|
||||||
|
UNIQUE (workspace_id, operation_id, role),
|
||||||
|
UNIQUE (workspace_id, operation_id, credential_id),
|
||||||
|
FOREIGN KEY (workspace_id, operation_id)
|
||||||
|
REFERENCES workdir_create_operations(workspace_id, operation_id)
|
||||||
|
ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_workdir_create_credential_candidates_revision
|
||||||
|
ON workdir_create_credential_candidates(
|
||||||
|
workspace_id, credential_id, credential_revision
|
||||||
|
);
|
||||||
|
CREATE TABLE workdir_create_credential_revision_retentions (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
operation_id TEXT NOT NULL,
|
||||||
|
ordinal INTEGER NOT NULL,
|
||||||
|
credential_id TEXT NOT NULL,
|
||||||
|
credential_revision INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (workspace_id, operation_id, ordinal),
|
||||||
|
FOREIGN KEY (workspace_id, operation_id, ordinal)
|
||||||
|
REFERENCES workdir_create_credential_candidates(
|
||||||
|
workspace_id, operation_id, ordinal
|
||||||
|
)
|
||||||
|
ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (workspace_id, credential_id, credential_revision)
|
||||||
|
REFERENCES repository_ssh_credential_revisions(
|
||||||
|
workspace_id, credential_id, revision
|
||||||
|
)
|
||||||
|
ON DELETE RESTRICT
|
||||||
|
);
|
||||||
CREATE TABLE "workdir_registry" (
|
CREATE TABLE "workdir_registry" (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
workdir_id TEXT NOT NULL,
|
workdir_id TEXT NOT NULL,
|
||||||
@@ -703,7 +775,7 @@ CREATE TABLE "worker_registry" (
|
|||||||
CREATE TABLE worker_removal_operations (
|
CREATE TABLE worker_removal_operations (
|
||||||
operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL,
|
operation_id TEXT PRIMARY KEY, plan_id TEXT NOT NULL UNIQUE, input_fingerprint TEXT NOT NULL,
|
||||||
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
||||||
worker_revision TEXT NOT NULL, run_generation INTEGER NOT NULL CHECK(run_generation>=0),
|
worker_revision TEXT NOT NULL,
|
||||||
policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
|
policy_id TEXT NOT NULL, policy_revision INTEGER NOT NULL,
|
||||||
session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL,
|
session_disposition TEXT NOT NULL, metadata_disposition TEXT NOT NULL,
|
||||||
archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER,
|
archive_retention_kind TEXT NOT NULL, archive_retention_seconds INTEGER,
|
||||||
@@ -795,6 +867,51 @@ CREATE TABLE workspace_create_operations (
|
|||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
CREATE TABLE workspace_signing_identities (
|
||||||
|
workspace_id TEXT PRIMARY KEY,
|
||||||
|
key_id TEXT NOT NULL UNIQUE,
|
||||||
|
algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'),
|
||||||
|
public_key TEXT,
|
||||||
|
public_key_fingerprint TEXT,
|
||||||
|
private_material_ref TEXT NOT NULL UNIQUE,
|
||||||
|
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||||
|
state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
provisioned_at TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
CHECK (
|
||||||
|
(state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL)
|
||||||
|
OR
|
||||||
|
(state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL)
|
||||||
|
),
|
||||||
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE TABLE workspace_signing_identity_provisioning_operations (
|
||||||
|
operation_key TEXT PRIMARY KEY,
|
||||||
|
request_fingerprint TEXT NOT NULL,
|
||||||
|
operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')),
|
||||||
|
workspace_id TEXT NOT NULL UNIQUE,
|
||||||
|
key_id TEXT NOT NULL UNIQUE,
|
||||||
|
private_material_ref TEXT NOT NULL UNIQUE,
|
||||||
|
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||||
|
actor_account_id TEXT NOT NULL,
|
||||||
|
state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE workspace_signing_identity_audit (
|
||||||
|
event_id TEXT PRIMARY KEY,
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
key_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL CHECK (action IN ('provisioned')),
|
||||||
|
revision INTEGER NOT NULL CHECK (revision >= 1),
|
||||||
|
public_key_fingerprint TEXT NOT NULL,
|
||||||
|
actor_account_id TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX workspace_signing_identity_audit_workspace_idx
|
||||||
|
ON workspace_signing_identity_audit(workspace_id, created_at DESC);
|
||||||
CREATE TABLE workspace_memory_documents (
|
CREATE TABLE workspace_memory_documents (
|
||||||
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
body_md TEXT NOT NULL,
|
body_md TEXT NOT NULL,
|
||||||
@@ -940,6 +1057,200 @@ CREATE UNIQUE INDEX worker_workdir_links_active_worker_unique
|
|||||||
WHERE unlinked_at IS NULL;
|
WHERE unlinked_at IS NULL;
|
||||||
CREATE INDEX worker_workdir_links_workdir
|
CREATE INDEX worker_workdir_links_workdir
|
||||||
ON worker_workdir_links(workspace_id, workdir_id);
|
ON worker_workdir_links(workspace_id, workdir_id);
|
||||||
|
CREATE TABLE runtime_removal_operations (
|
||||||
|
operation_id TEXT PRIMARY KEY,
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
runtime_id TEXT NOT NULL,
|
||||||
|
request_fingerprint TEXT NOT NULL,
|
||||||
|
expected_binding_revision INTEGER NOT NULL,
|
||||||
|
config_revision INTEGER NOT NULL,
|
||||||
|
state TEXT NOT NULL CHECK (state IN ('pending', 'cleanup_pending', 'succeeded', 'failed')),
|
||||||
|
failure_category TEXT,
|
||||||
|
binding_removed INTEGER NOT NULL CHECK (binding_removed IN (0, 1)),
|
||||||
|
runtime_registration_removed INTEGER CHECK (runtime_registration_removed IS NULL OR runtime_registration_removed IN (0, 1)),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
completed_at TEXT,
|
||||||
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX runtime_removal_operations_one_active_runtime
|
||||||
|
ON runtime_removal_operations(runtime_id)
|
||||||
|
WHERE state IN ('pending', 'cleanup_pending');
|
||||||
|
|
||||||
|
CREATE INDEX runtime_removal_operations_workspace_state
|
||||||
|
ON runtime_removal_operations(workspace_id, state, updated_at);
|
||||||
|
|
||||||
|
CREATE TRIGGER runtime_binding_insert_blocked_by_removal
|
||||||
|
BEFORE INSERT ON workspace_runtime_bindings
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'runtime_removal_in_progress');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER runtime_binding_update_blocked_by_removal
|
||||||
|
BEFORE UPDATE ON workspace_runtime_bindings
|
||||||
|
FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
SELECT RAISE(ABORT, 'runtime_removal_in_progress');
|
||||||
|
END;
|
||||||
|
|
||||||
|
CREATE TRIGGER worker_registry_insert_blocked_by_runtime_removal
|
||||||
|
BEFORE INSERT ON worker_registry FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER worker_registry_update_blocked_by_runtime_removal
|
||||||
|
BEFORE UPDATE ON worker_registry FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER workdir_registry_insert_blocked_by_runtime_removal
|
||||||
|
BEFORE INSERT ON workdir_registry FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER workdir_registry_update_blocked_by_runtime_removal
|
||||||
|
BEFORE UPDATE ON workdir_registry FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER worker_assignment_insert_blocked_by_runtime_removal
|
||||||
|
BEFORE INSERT ON ticket_current_worker_assignments FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER worker_assignment_update_blocked_by_runtime_removal
|
||||||
|
BEFORE UPDATE ON ticket_current_worker_assignments FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal
|
||||||
|
BEFORE INSERT ON worker_workdir_links FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal
|
||||||
|
BEFORE UPDATE ON worker_workdir_links FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER worker_create_insert_blocked_by_runtime_removal
|
||||||
|
BEFORE INSERT ON worker_create_reservations FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER worker_create_update_blocked_by_runtime_removal
|
||||||
|
BEFORE UPDATE ON worker_create_reservations FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal
|
||||||
|
BEFORE INSERT ON workdir_create_operations FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.resolved_runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER workdir_create_update_blocked_by_runtime_removal
|
||||||
|
BEFORE UPDATE ON workdir_create_operations FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.resolved_runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER worker_removal_insert_blocked_by_runtime_removal
|
||||||
|
BEFORE INSERT ON worker_removal_operations FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER worker_removal_update_blocked_by_runtime_removal
|
||||||
|
BEFORE UPDATE ON worker_removal_operations FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER workdir_removal_insert_blocked_by_runtime_removal
|
||||||
|
BEFORE INSERT ON workdir_removal_operations FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
|
CREATE TRIGGER workdir_removal_update_blocked_by_runtime_removal
|
||||||
|
BEFORE UPDATE ON workdir_removal_operations FOR EACH ROW
|
||||||
|
WHEN EXISTS (
|
||||||
|
SELECT 1 FROM runtime_removal_operations operation
|
||||||
|
WHERE operation.runtime_id = NEW.runtime_id
|
||||||
|
AND operation.state IN ('pending', 'cleanup_pending')
|
||||||
|
)
|
||||||
|
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||||
|
|
||||||
CREATE TABLE workspace_deletion_operations (
|
CREATE TABLE workspace_deletion_operations (
|
||||||
operation_id TEXT PRIMARY KEY,
|
operation_id TEXT PRIMARY KEY,
|
||||||
request_fingerprint TEXT NOT NULL,
|
request_fingerprint TEXT NOT NULL,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ pub mod companion;
|
|||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod config_source;
|
pub mod config_source;
|
||||||
pub mod hosts;
|
pub mod hosts;
|
||||||
pub mod identity;
|
|
||||||
pub mod memory_backend;
|
pub mod memory_backend;
|
||||||
pub mod memory_staging;
|
pub mod memory_staging;
|
||||||
pub mod observation;
|
pub mod observation;
|
||||||
@@ -34,6 +33,7 @@ mod workdir_removal;
|
|||||||
pub mod worker_source;
|
pub mod worker_source;
|
||||||
pub mod workspace_catalog;
|
pub mod workspace_catalog;
|
||||||
mod workspace_deletion;
|
mod workspace_deletion;
|
||||||
|
pub mod workspace_signing_identity;
|
||||||
mod workspace_subscription;
|
mod workspace_subscription;
|
||||||
|
|
||||||
pub use authority::{
|
pub use authority::{
|
||||||
@@ -42,7 +42,6 @@ pub use authority::{
|
|||||||
WorkspaceAuthority,
|
WorkspaceAuthority,
|
||||||
};
|
};
|
||||||
pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
|
pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
|
||||||
pub use identity::{WORKSPACE_IDENTITY_RELATIVE_PATH, WorkspaceIdentity};
|
|
||||||
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
|
pub use records::{ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary};
|
||||||
pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader};
|
pub use repositories::{ConfiguredRepository, RepositoryLogRead, RepositoryRegistryReader};
|
||||||
pub use server::{
|
pub use server::{
|
||||||
@@ -136,8 +135,8 @@ pub enum Error {
|
|||||||
RegistryInconsistency(String),
|
RegistryInconsistency(String),
|
||||||
#[error("Worker source identity is invalid: {0}")]
|
#[error("Worker source identity is invalid: {0}")]
|
||||||
WorkerSourceIdentity(String),
|
WorkerSourceIdentity(String),
|
||||||
#[error("workspace identity error: {0}")]
|
#[error("Workspace signing identity error ({code}): {message}")]
|
||||||
WorkspaceIdentity(String),
|
WorkspaceSigningIdentity { code: String, message: String },
|
||||||
#[error("store error: {0}")]
|
#[error("store error: {0}")]
|
||||||
Store(String),
|
Store(String),
|
||||||
}
|
}
|
||||||
|
|||||||
+116
-458
@@ -1,25 +1,25 @@
|
|||||||
use std::collections::VecDeque;
|
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::PathBuf;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
|
use yoi_workspace_server::hosts::{
|
||||||
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
|
EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig, is_loopback_runtime_origin,
|
||||||
use yoi_workspace_server::store::{SqliteWorkspaceStore, WorkspaceRuntimeBinding};
|
};
|
||||||
|
use yoi_workspace_server::store::{
|
||||||
|
SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
|
||||||
|
WorkspaceRuntimeBindingState,
|
||||||
|
};
|
||||||
use yoi_workspace_server::{
|
use yoi_workspace_server::{
|
||||||
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
|
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
|
||||||
WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
|
WorkspaceRecord, serve_workspace_catalog,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
enum Command {
|
enum Command {
|
||||||
Serve(ServeOptions),
|
Serve(ServeOptions),
|
||||||
Identity(Vec<String>),
|
|
||||||
TrustRuntime(Vec<String>),
|
|
||||||
Migrate(MigrateOptions),
|
Migrate(MigrateOptions),
|
||||||
Skills(SkillsCommand),
|
Skills(SkillsCommand),
|
||||||
Help,
|
Help,
|
||||||
@@ -76,8 +76,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
let args = std::env::args().skip(1).collect::<Vec<_>>();
|
||||||
match parse_command(&args)? {
|
match parse_command(&args)? {
|
||||||
Command::Serve(options) => run_serve(options).await,
|
Command::Serve(options) => run_serve(options).await,
|
||||||
Command::Identity(args) => run_identity_command(args),
|
|
||||||
Command::TrustRuntime(args) => run_trust_runtime_command(args),
|
|
||||||
Command::Migrate(options) => run_migrate(options),
|
Command::Migrate(options) => run_migrate(options),
|
||||||
Command::Skills(command) => run_skills(command),
|
Command::Skills(command) => run_skills(command),
|
||||||
Command::Help => Ok(()),
|
Command::Help => Ok(()),
|
||||||
@@ -91,8 +89,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match command.as_str() {
|
match command.as_str() {
|
||||||
"identity" => Ok(Command::Identity(rest.to_vec())),
|
|
||||||
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
|
|
||||||
"migrate" => parse_migrate_options(rest).map(Command::Migrate),
|
"migrate" => parse_migrate_options(rest).map(Command::Migrate),
|
||||||
"skills" => parse_skills_command(rest),
|
"skills" => parse_skills_command(rest),
|
||||||
"serve" => {
|
"serve" => {
|
||||||
@@ -107,371 +103,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
|||||||
Ok(Command::Help)
|
Ok(Command::Help)
|
||||||
}
|
}
|
||||||
other => Err(CliError(format!(
|
other => Err(CliError(format!(
|
||||||
"unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
|
"unknown command `{other}`; expected `migrate`, `skills`, or `serve`"
|
||||||
))),
|
))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
struct ServerIdentityFile {
|
|
||||||
identity: RuntimeIdentityMaterial,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
struct PublicIdentityView {
|
|
||||||
identity_id: String,
|
|
||||||
public_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn server_identity_path() -> PathBuf {
|
|
||||||
ServerConfig::default_server_data_root().join("identity.toml")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_server_identity_file(
|
|
||||||
path: &Path,
|
|
||||||
) -> Result<Option<ServerIdentityFile>, Box<dyn std::error::Error>> {
|
|
||||||
if !path.exists() {
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
let contents = std::fs::read_to_string(path)?;
|
|
||||||
Ok(Some(toml::from_str(&contents)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_server_identity_file(
|
|
||||||
path: &Path,
|
|
||||||
identity: &ServerIdentityFile,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
if let Some(parent) = path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let contents = toml::to_string_pretty(identity)?;
|
|
||||||
write_secret_file(path, contents.as_bytes())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn write_secret_file(path: &Path, contents: &[u8]) -> std::io::Result<()> {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::OpenOptionsExt;
|
|
||||||
let mut file = std::fs::OpenOptions::new()
|
|
||||||
.create(true)
|
|
||||||
.write(true)
|
|
||||||
.truncate(true)
|
|
||||||
.mode(0o600)
|
|
||||||
.open(path)?;
|
|
||||||
use std::io::Write as _;
|
|
||||||
file.write_all(contents)?;
|
|
||||||
}
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
std::fs::write(path, contents)?;
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn public_identity_view(identity: &RuntimeIdentityMaterial) -> PublicIdentityView {
|
|
||||||
PublicIdentityView {
|
|
||||||
identity_id: identity.identity_id.clone(),
|
|
||||||
public_key: identity.public_key.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_identity_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let mut args = VecDeque::from(args);
|
|
||||||
let subcommand = args
|
|
||||||
.pop_front()
|
|
||||||
.ok_or_else(|| CliError("identity requires `init` or `show`".to_string()))?;
|
|
||||||
match subcommand.as_str() {
|
|
||||||
"init" => {
|
|
||||||
let mut server_id = None;
|
|
||||||
let mut replace = false;
|
|
||||||
while let Some(arg) = args.pop_front() {
|
|
||||||
let (flag, inline_value) = split_flag_value(arg)?;
|
|
||||||
match flag.as_str() {
|
|
||||||
"--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?),
|
|
||||||
"--replace" => {
|
|
||||||
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
|
||||||
replace = true;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(Box::new(CliError(format!(
|
|
||||||
"unknown identity init argument `{flag}`"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let server_id = server_id
|
|
||||||
.ok_or_else(|| CliError("identity init requires --server-id".to_string()))?;
|
|
||||||
let path = server_identity_path();
|
|
||||||
if read_server_identity_file(&path)?.is_some() && !replace {
|
|
||||||
return Err(Box::new(CliError(format!(
|
|
||||||
"server identity already exists at {}; pass --replace to rotate it",
|
|
||||||
path.display()
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
let identity = RuntimeIdentityMaterial::generate(server_id)?;
|
|
||||||
write_server_identity_file(
|
|
||||||
&path,
|
|
||||||
&ServerIdentityFile {
|
|
||||||
identity: identity.clone(),
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
println!("server_id={}", identity.identity_id);
|
|
||||||
println!("public_key={}", identity.public_key);
|
|
||||||
println!("identity_file={}", path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
"show" => {
|
|
||||||
let mut json = false;
|
|
||||||
while let Some(arg) = args.pop_front() {
|
|
||||||
let (flag, inline_value) = split_flag_value(arg)?;
|
|
||||||
match flag.as_str() {
|
|
||||||
"--json" => {
|
|
||||||
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
|
||||||
json = true;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(Box::new(CliError(format!(
|
|
||||||
"unknown identity show argument `{flag}`"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let path = server_identity_path();
|
|
||||||
let identity = read_server_identity_file(&path)?.ok_or_else(|| {
|
|
||||||
CliError(format!(
|
|
||||||
"server identity is not initialized at {}",
|
|
||||||
path.display()
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let view = public_identity_view(&identity.identity);
|
|
||||||
if json {
|
|
||||||
println!("{}", serde_json::to_string_pretty(&view)?);
|
|
||||||
} else {
|
|
||||||
println!("server_id={}", view.identity_id);
|
|
||||||
println!("public_key={}", view.public_key);
|
|
||||||
println!("identity_file={}", path.display());
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
_ => Err(Box::new(CliError(format!(
|
|
||||||
"unknown identity subcommand `{subcommand}`"
|
|
||||||
)))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let mut args = VecDeque::from(args);
|
|
||||||
let subcommand = args
|
|
||||||
.pop_front()
|
|
||||||
.ok_or_else(|| CliError("trust-runtime requires `add`, `list`, or `revoke`".to_string()))?;
|
|
||||||
let database_path = ServerConfig::default_server_database_path();
|
|
||||||
if let Some(parent) = database_path.parent() {
|
|
||||||
std::fs::create_dir_all(parent)?;
|
|
||||||
}
|
|
||||||
let store = SqliteWorkspaceStore::open(&database_path)?;
|
|
||||||
match subcommand.as_str() {
|
|
||||||
"add" => {
|
|
||||||
let mut runtime_id = None;
|
|
||||||
let mut workspace_id = None;
|
|
||||||
let mut base_url = None;
|
|
||||||
let mut public_key = None;
|
|
||||||
let mut display_name = None;
|
|
||||||
let mut replace = false;
|
|
||||||
while let Some(arg) = args.pop_front() {
|
|
||||||
let (flag, inline_value) = split_flag_value(arg)?;
|
|
||||||
match flag.as_str() {
|
|
||||||
"--runtime-id" => {
|
|
||||||
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
|
|
||||||
}
|
|
||||||
"--workspace-id" => {
|
|
||||||
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
|
|
||||||
}
|
|
||||||
"--base-url" | "--endpoint" => {
|
|
||||||
base_url = Some(take_value(&flag, inline_value, &mut args)?)
|
|
||||||
}
|
|
||||||
"--public-key" => {
|
|
||||||
public_key = Some(take_value(&flag, inline_value, &mut args)?)
|
|
||||||
}
|
|
||||||
"--display-name" => {
|
|
||||||
display_name = Some(take_value(&flag, inline_value, &mut args)?)
|
|
||||||
}
|
|
||||||
"--replace" => {
|
|
||||||
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
|
||||||
replace = true;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(Box::new(CliError(format!(
|
|
||||||
"unknown trust-runtime add argument `{flag}`"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let runtime_id = runtime_id
|
|
||||||
.ok_or_else(|| CliError("trust-runtime add requires --runtime-id".to_string()))?;
|
|
||||||
let workspace_id = workspace_id
|
|
||||||
.ok_or_else(|| CliError("trust-runtime add requires --workspace-id".to_string()))?;
|
|
||||||
if !store
|
|
||||||
.list_workspaces()?
|
|
||||||
.iter()
|
|
||||||
.any(|workspace| workspace.workspace_id == workspace_id)
|
|
||||||
{
|
|
||||||
return Err(Box::new(CliError(format!(
|
|
||||||
"Workspace `{workspace_id}` is not registered"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
let base_url = base_url
|
|
||||||
.ok_or_else(|| CliError("trust-runtime add requires --base-url".to_string()))?;
|
|
||||||
let public_key = public_key
|
|
||||||
.ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?;
|
|
||||||
decode_public_key(&public_key)?;
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
let outcome = store.upsert_workspace_runtime_binding(
|
|
||||||
WorkspaceRuntimeBinding {
|
|
||||||
workspace_id: workspace_id.clone(),
|
|
||||||
runtime_id: runtime_id.clone(),
|
|
||||||
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
|
|
||||||
base_url,
|
|
||||||
public_key,
|
|
||||||
public_key_fingerprint: String::new(),
|
|
||||||
binding_revision: 1,
|
|
||||||
created_at: now.clone(),
|
|
||||||
updated_at: now,
|
|
||||||
revoked_at: None,
|
|
||||||
},
|
|
||||||
replace,
|
|
||||||
)?;
|
|
||||||
println!("workspace_id={workspace_id}");
|
|
||||||
println!("runtime_id={runtime_id}");
|
|
||||||
println!(
|
|
||||||
"result={}",
|
|
||||||
match outcome {
|
|
||||||
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Created =>
|
|
||||||
"created",
|
|
||||||
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged =>
|
|
||||||
"unchanged",
|
|
||||||
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Replaced =>
|
|
||||||
"replaced",
|
|
||||||
}
|
|
||||||
);
|
|
||||||
println!("server_db={}", database_path.display());
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
"list" => {
|
|
||||||
let mut workspace_id = None;
|
|
||||||
let mut json = false;
|
|
||||||
let mut include_revoked = false;
|
|
||||||
while let Some(arg) = args.pop_front() {
|
|
||||||
let (flag, inline_value) = split_flag_value(arg)?;
|
|
||||||
match flag.as_str() {
|
|
||||||
"--workspace-id" => {
|
|
||||||
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
|
|
||||||
}
|
|
||||||
"--json" => {
|
|
||||||
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
|
||||||
json = true;
|
|
||||||
}
|
|
||||||
"--include-revoked" => {
|
|
||||||
ensure_no_inline_value(&flag, inline_value.as_deref())?;
|
|
||||||
include_revoked = true;
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(Box::new(CliError(format!(
|
|
||||||
"unknown trust-runtime list argument `{flag}`"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let workspace_id = workspace_id.ok_or_else(|| {
|
|
||||||
CliError("trust-runtime list requires --workspace-id".to_string())
|
|
||||||
})?;
|
|
||||||
let records = store.list_workspace_runtime_bindings(&workspace_id, include_revoked)?;
|
|
||||||
if json {
|
|
||||||
println!("{}", serde_json::to_string_pretty(&records)?);
|
|
||||||
} else {
|
|
||||||
for runtime in records {
|
|
||||||
println!(
|
|
||||||
"workspace_id={} runtime_id={} base_url={} public_key_fingerprint={} revoked_at={}",
|
|
||||||
runtime.workspace_id,
|
|
||||||
runtime.runtime_id,
|
|
||||||
runtime.base_url,
|
|
||||||
runtime.public_key_fingerprint,
|
|
||||||
runtime.revoked_at.unwrap_or_default()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
"revoke" => {
|
|
||||||
let mut workspace_id = None;
|
|
||||||
let mut runtime_id = None;
|
|
||||||
while let Some(arg) = args.pop_front() {
|
|
||||||
let (flag, inline_value) = split_flag_value(arg)?;
|
|
||||||
match flag.as_str() {
|
|
||||||
"--workspace-id" => {
|
|
||||||
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
|
|
||||||
}
|
|
||||||
"--runtime-id" => {
|
|
||||||
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(Box::new(CliError(format!(
|
|
||||||
"unknown trust-runtime revoke argument `{flag}`"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let workspace_id = workspace_id.ok_or_else(|| {
|
|
||||||
CliError("trust-runtime revoke requires --workspace-id".to_string())
|
|
||||||
})?;
|
|
||||||
let runtime_id = runtime_id.ok_or_else(|| {
|
|
||||||
CliError("trust-runtime revoke requires --runtime-id".to_string())
|
|
||||||
})?;
|
|
||||||
let now = Utc::now().to_rfc3339();
|
|
||||||
if !store.revoke_workspace_runtime_binding(&workspace_id, &runtime_id, &now)? {
|
|
||||||
return Err(Box::new(CliError(format!(
|
|
||||||
"trusted runtime `{runtime_id}` is not registered or is already revoked"
|
|
||||||
))));
|
|
||||||
}
|
|
||||||
println!("revoked_runtime_id={runtime_id}");
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
_ => Err(Box::new(CliError(format!(
|
|
||||||
"unknown trust-runtime subcommand `{subcommand}`"
|
|
||||||
)))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn split_flag_value(arg: String) -> Result<(String, Option<String>), CliError> {
|
|
||||||
if let Some((flag, value)) = arg.split_once('=') {
|
|
||||||
if flag.is_empty() {
|
|
||||||
return Err(CliError("empty flag name".to_string()));
|
|
||||||
}
|
|
||||||
Ok((flag.to_string(), Some(value.to_string())))
|
|
||||||
} else {
|
|
||||||
Ok((arg, None))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn take_value(
|
|
||||||
flag: &str,
|
|
||||||
inline_value: Option<String>,
|
|
||||||
args: &mut VecDeque<String>,
|
|
||||||
) -> Result<String, CliError> {
|
|
||||||
if let Some(value) = inline_value {
|
|
||||||
return Ok(value);
|
|
||||||
}
|
|
||||||
args.pop_front()
|
|
||||||
.ok_or_else(|| CliError(format!("{flag} requires a value")))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ensure_no_inline_value(flag: &str, inline_value: Option<&str>) -> Result<(), CliError> {
|
|
||||||
if inline_value.is_some() {
|
|
||||||
return Err(CliError(format!("{flag} does not accept a value")));
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
|
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match command {
|
match command {
|
||||||
SkillsCommand::List(options) => {
|
SkillsCommand::List(options) => {
|
||||||
@@ -519,6 +155,31 @@ fn load_skill_workspace_config(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn remote_runtime_config_from_binding(
|
||||||
|
binding: WorkspaceRuntimeBinding,
|
||||||
|
) -> Result<Option<RemoteRuntimeConfig>, CliError> {
|
||||||
|
if binding.runtime_id == EMBEDDED_RUNTIME_ID {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity {
|
||||||
|
return Err(CliError(format!(
|
||||||
|
"Runtime binding '{}:{}' still uses removed legacy Server-issued authentication",
|
||||||
|
binding.workspace_id, binding.runtime_id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let strict_public_egress = !is_loopback_runtime_origin(&binding.base_url);
|
||||||
|
Ok(Some(
|
||||||
|
RemoteRuntimeConfig::new(
|
||||||
|
binding.runtime_id,
|
||||||
|
binding.display_name,
|
||||||
|
binding.base_url,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.with_workspace_id(binding.workspace_id)
|
||||||
|
.with_strict_public_egress(strict_public_egress),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
fn run_migrate(options: MigrateOptions) -> Result<(), Box<dyn std::error::Error>> {
|
fn run_migrate(options: MigrateOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
if options.help {
|
if options.help {
|
||||||
print_migrate_help();
|
print_migrate_help();
|
||||||
@@ -574,21 +235,21 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
|
|
||||||
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
|
let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?);
|
||||||
let workspaces = store.list_workspaces()?;
|
let workspaces = store.list_workspaces()?;
|
||||||
let (identity, workspace_root) = if let Some(workspace) = workspaces.first() {
|
let (workspace, workspace_execution_root) = if let Some(workspace) = workspaces.first() {
|
||||||
(
|
(
|
||||||
WorkspaceIdentity {
|
workspace.clone(),
|
||||||
workspace_id: workspace.workspace_id.clone(),
|
workspace_execution_root_from_server_data(workspace)?,
|
||||||
created_at: workspace.created_at.clone(),
|
|
||||||
display_name: workspace.display_name.clone(),
|
|
||||||
},
|
|
||||||
workspace_root_from_server_data(workspace)?,
|
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
|
let now = Utc::now().to_rfc3339();
|
||||||
(
|
(
|
||||||
WorkspaceIdentity {
|
WorkspaceRecord {
|
||||||
workspace_id: "00000000-0000-0000-0000-000000000000".to_string(),
|
workspace_id: "00000000-0000-0000-0000-000000000000".to_string(),
|
||||||
created_at: Utc::now().to_rfc3339(),
|
owner_account_id: "00000000-0000-0000-0000-000000000000".to_string(),
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now,
|
||||||
display_name: "Server bootstrap".to_string(),
|
display_name: "Server bootstrap".to_string(),
|
||||||
|
state: "bootstrap".to_string(),
|
||||||
},
|
},
|
||||||
database_path
|
database_path
|
||||||
.parent()
|
.parent()
|
||||||
@@ -600,8 +261,11 @@ async fn run_serve(options: ServeOptions) -> Result<(), Box<dyn std::error::Erro
|
|||||||
Some(path) => ServerHostConfigFile::load_from_path(path)?,
|
Some(path) => ServerHostConfigFile::load_from_path(path)?,
|
||||||
None => ServerHostConfigFile::load_default()?,
|
None => ServerHostConfigFile::load_default()?,
|
||||||
};
|
};
|
||||||
let mut resolved =
|
let mut resolved = ResolvedWorkspaceBackendConfig::local_dev(
|
||||||
ResolvedWorkspaceBackendConfig::local_dev(&workspace_root, identity, &host_config)?;
|
&workspace_execution_root,
|
||||||
|
workspace,
|
||||||
|
&host_config,
|
||||||
|
)?;
|
||||||
resolved.database_path = database_path.clone();
|
resolved.database_path = database_path.clone();
|
||||||
resolved.server.database_path = database_path.clone();
|
resolved.server.database_path = database_path.clone();
|
||||||
append_workspace_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
|
append_workspace_runtime_sources(store.as_ref(), &mut resolved.server.remote_runtime_sources)?;
|
||||||
@@ -639,6 +303,7 @@ fn append_workspace_runtime_sources(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|binding| {
|
.filter(|binding| {
|
||||||
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
|
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
|
||||||
|
&& binding.state == WorkspaceRuntimeBindingState::Verified
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
@@ -647,37 +312,22 @@ fn append_workspace_runtime_sources(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.flatten()
|
.flatten()
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
let Some(server_identity) = read_server_identity_file(&server_identity_path())? else {
|
for binding in bindings {
|
||||||
if !bindings.is_empty() {
|
let Some(remote) = remote_runtime_config_from_binding(binding)? else {
|
||||||
return Err(Box::new(CliError(
|
continue;
|
||||||
"Runtime bindings are registered but server identity is not initialized; run `yoi-server identity init`".to_string(),
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
return Ok(());
|
|
||||||
};
|
|
||||||
for runtime in bindings {
|
|
||||||
let auth = RemoteRuntimeAuthConfig {
|
|
||||||
server_id: server_identity.identity.identity_id.clone(),
|
|
||||||
server_private_key: server_identity.identity.private_key.clone(),
|
|
||||||
};
|
};
|
||||||
let remote = RemoteRuntimeConfig::new(
|
|
||||||
runtime.runtime_id.clone(),
|
|
||||||
runtime.display_name,
|
|
||||||
runtime.base_url,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.with_workspace_id(runtime.workspace_id.clone())
|
|
||||||
.with_auth(auth);
|
|
||||||
remote_runtime_sources.retain(|existing| {
|
remote_runtime_sources.retain(|existing| {
|
||||||
existing.workspace_id.as_deref() != Some(runtime.workspace_id.as_str())
|
existing.workspace_id.as_deref() != remote.workspace_id.as_deref()
|
||||||
|| existing.runtime_id != runtime.runtime_id
|
|| existing.runtime_id != remote.runtime_id
|
||||||
});
|
});
|
||||||
remote_runtime_sources.push(remote);
|
remote_runtime_sources.push(remote);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_root_from_server_data(workspace: &WorkspaceRecord) -> Result<PathBuf, CliError> {
|
fn workspace_execution_root_from_server_data(
|
||||||
|
workspace: &WorkspaceRecord,
|
||||||
|
) -> Result<PathBuf, CliError> {
|
||||||
Ok(ServerConfig::default_workspace_backend_data_root(
|
Ok(ServerConfig::default_workspace_backend_data_root(
|
||||||
&workspace.workspace_id,
|
&workspace.workspace_id,
|
||||||
))
|
))
|
||||||
@@ -840,7 +490,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
|
|||||||
|
|
||||||
fn print_help() {
|
fn print_help() {
|
||||||
println!(
|
println!(
|
||||||
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list --workspace-id <WORKSPACE_ID> [--json] [--include-revoked]\n yoi-server trust-runtime revoke --workspace-id <WORKSPACE_ID> --runtime-id <RUNTIME_ID>\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
"yoi-server\n\nUsage:\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -952,60 +602,68 @@ mod tests {
|
|||||||
"unknown serve option `--frontend=/tmp/web`"
|
"unknown serve option `--frontend=/tmp/web`"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn server_identity_init_requires_explicit_server_id() {
|
fn runtime_startup_rejects_legacy_server_issuer_bindings() {
|
||||||
let error = run_identity_command(vec!["init".to_string()]).unwrap_err();
|
|
||||||
assert_eq!(error.to_string(), "identity init requires --server-id");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn runtime_binding_requires_explicit_replace_for_changed_authority() {
|
|
||||||
let temp = tempfile::tempdir().unwrap();
|
|
||||||
let path = temp.path().join("server.db");
|
|
||||||
let store = SqliteWorkspaceStore::open(&path).unwrap();
|
|
||||||
rusqlite::Connection::open(&path)
|
|
||||||
.unwrap()
|
|
||||||
.execute_batch(
|
|
||||||
"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at)
|
|
||||||
VALUES ('owner', 'user', 'owner', 'Owner', '1', '1');
|
|
||||||
INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at)
|
|
||||||
VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1');",
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let public_key = RuntimeIdentityMaterial::generate("runtime-a")
|
|
||||||
.unwrap()
|
|
||||||
.public_key;
|
|
||||||
let binding = WorkspaceRuntimeBinding {
|
let binding = WorkspaceRuntimeBinding {
|
||||||
workspace_id: "workspace-a".to_string(),
|
workspace_id: "workspace-a".to_owned(),
|
||||||
runtime_id: "runtime-a".to_string(),
|
runtime_id: "runtime-a".to_owned(),
|
||||||
display_name: "Runtime A".to_string(),
|
display_name: "Runtime A".to_owned(),
|
||||||
base_url: "http://127.0.0.1:18080".to_string(),
|
base_url: "https://runtime.example.test".to_owned(),
|
||||||
public_key,
|
public_key: "unused".to_owned(),
|
||||||
public_key_fingerprint: String::new(),
|
public_key_fingerprint: "unused".to_owned(),
|
||||||
binding_revision: 1,
|
binding_revision: 1,
|
||||||
created_at: "2026-07-26T00:00:00Z".to_string(),
|
state: WorkspaceRuntimeBindingState::Verified,
|
||||||
updated_at: "2026-07-26T00:00:00Z".to_string(),
|
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
|
||||||
|
workspace_key_id: None,
|
||||||
|
workspace_key_generation: None,
|
||||||
|
created_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||||
|
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
};
|
};
|
||||||
store
|
let error = remote_runtime_config_from_binding(binding)
|
||||||
.upsert_workspace_runtime_binding(binding.clone(), false)
|
.unwrap_err()
|
||||||
.unwrap();
|
.to_string();
|
||||||
assert!(matches!(
|
assert_eq!(
|
||||||
store
|
error,
|
||||||
.upsert_workspace_runtime_binding(binding.clone(), false)
|
"Runtime binding 'workspace-a:runtime-a' still uses removed legacy Server-issued authentication"
|
||||||
.unwrap(),
|
|
||||||
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged
|
|
||||||
));
|
|
||||||
let mut changed = binding;
|
|
||||||
changed.base_url = "http://127.0.0.1:18081".to_string();
|
|
||||||
assert!(
|
|
||||||
store
|
|
||||||
.upsert_workspace_runtime_binding(changed.clone(), false)
|
|
||||||
.is_err()
|
|
||||||
);
|
);
|
||||||
store
|
}
|
||||||
.upsert_workspace_runtime_binding(changed, true)
|
|
||||||
.unwrap();
|
#[test]
|
||||||
|
fn runtime_startup_uses_non_strict_transport_for_literal_loopback_origin() {
|
||||||
|
let binding = WorkspaceRuntimeBinding {
|
||||||
|
workspace_id: "workspace-a".to_owned(),
|
||||||
|
runtime_id: "arcadia".to_owned(),
|
||||||
|
display_name: "Arcadia".to_owned(),
|
||||||
|
base_url: "http://127.0.0.1:8788".to_owned(),
|
||||||
|
public_key: "unused".to_owned(),
|
||||||
|
public_key_fingerprint: "unused".to_owned(),
|
||||||
|
binding_revision: 1,
|
||||||
|
state: WorkspaceRuntimeBindingState::Verified,
|
||||||
|
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
||||||
|
workspace_key_id: Some("WK-test".to_owned()),
|
||||||
|
workspace_key_generation: Some(1),
|
||||||
|
created_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||||
|
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||||
|
revoked_at: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let config = remote_runtime_config_from_binding(binding)
|
||||||
|
.unwrap()
|
||||||
|
.expect("remote Runtime config");
|
||||||
|
|
||||||
|
assert_eq!(config.base_url, "http://127.0.0.1:8788");
|
||||||
|
assert!(!config.strict_public_egress);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_cli_rejects_removed_server_global_runtime_trust_commands() {
|
||||||
|
for command in ["identity", "trust-runtime"] {
|
||||||
|
let error = parse_command(&[command.to_owned()]).unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
error.to_string(),
|
||||||
|
format!("unknown command `{command}`; expected `migrate`, `skills`, or `serve`")
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs;
|
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::time::UNIX_EPOCH;
|
|
||||||
|
|
||||||
use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath};
|
use config_source::{ConfigContentType, ConfigSchemaContribution, VirtualPath};
|
||||||
use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value};
|
use manifest::{ProfileSource, builtin_profile_catalog_snapshot, resolve_profile_artifact_value};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use worker::EffectivePromptCatalog;
|
use worker::EffectivePromptCatalog;
|
||||||
use worker_runtime::config_bundle::{
|
use worker_runtime::config_bundle::{
|
||||||
@@ -13,14 +11,14 @@ use worker_runtime::config_bundle::{
|
|||||||
};
|
};
|
||||||
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
|
||||||
use workspace_api::{
|
use workspace_api::{
|
||||||
Diagnostic, DiagnosticSeverity, ProfileSettingsResponse, UpdateWorkspaceMetadataRequest,
|
ProfileSettingsResponse, WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance,
|
||||||
WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceProvenance,
|
|
||||||
WorkspaceProfileSourceSummary, WorkspaceProfileSummary,
|
WorkspaceProfileSourceSummary, WorkspaceProfileSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::config_source::{
|
use crate::config_source::{
|
||||||
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
|
WorkspaceConfigSchemaProvider, WorkspaceConfigState, evaluate_workspace_config_state,
|
||||||
};
|
};
|
||||||
|
use crate::store::WorkspaceRecord;
|
||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
const PROFILE_SCHEMA_SOURCE: &str = r#"{
|
const PROFILE_SCHEMA_SOURCE: &str = r#"{
|
||||||
@@ -467,103 +465,29 @@ fn build_virtual_profile_archive(
|
|||||||
.map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string()))
|
.map_err(|error| profile_validation_error("profile_source_archive_invalid", &error.to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
struct WorkspaceIdentityFile {
|
|
||||||
workspace_id: String,
|
|
||||||
created_at: String,
|
|
||||||
display_name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn workspace_metadata_settings(
|
pub fn workspace_metadata_settings(
|
||||||
workspace_root: &Path,
|
workspace: &WorkspaceRecord,
|
||||||
fallback_workspace_id: &str,
|
|
||||||
fallback_created_at: &str,
|
|
||||||
fallback_display_name: &str,
|
|
||||||
) -> WorkspaceMetadataSettingsResponse {
|
) -> WorkspaceMetadataSettingsResponse {
|
||||||
let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH);
|
|
||||||
let mut diagnostics = Vec::new();
|
|
||||||
let (workspace_id, created_at, display_name) = match fs::read_to_string(&path) {
|
|
||||||
Ok(raw) => match toml::from_str::<WorkspaceIdentityFile>(&raw) {
|
|
||||||
Ok(file) => (file.workspace_id, file.created_at, file.display_name),
|
|
||||||
Err(err) => {
|
|
||||||
diagnostics.push(diagnostic(
|
|
||||||
"workspace_identity_parse_failed",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
format!("Workspace identity could not be parsed: {err}"),
|
|
||||||
));
|
|
||||||
(
|
|
||||||
fallback_workspace_id.to_string(),
|
|
||||||
fallback_created_at.to_string(),
|
|
||||||
fallback_display_name.to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
||||||
diagnostics.push(diagnostic(
|
|
||||||
"workspace_identity_missing",
|
|
||||||
DiagnosticSeverity::Warning,
|
|
||||||
"Workspace identity record is missing; showing active backend metadata.",
|
|
||||||
));
|
|
||||||
(
|
|
||||||
fallback_workspace_id.to_string(),
|
|
||||||
fallback_created_at.to_string(),
|
|
||||||
fallback_display_name.to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
diagnostics.push(diagnostic(
|
|
||||||
"workspace_identity_read_failed",
|
|
||||||
DiagnosticSeverity::Error,
|
|
||||||
format!(
|
|
||||||
"Workspace identity could not be read: {}",
|
|
||||||
sanitize_error(&err.to_string())
|
|
||||||
),
|
|
||||||
));
|
|
||||||
(
|
|
||||||
fallback_workspace_id.to_string(),
|
|
||||||
fallback_created_at.to_string(),
|
|
||||||
fallback_display_name.to_string(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
WorkspaceMetadataSettingsResponse {
|
WorkspaceMetadataSettingsResponse {
|
||||||
workspace_id,
|
workspace_id: workspace.workspace_id.clone(),
|
||||||
display_name,
|
display_name: workspace.display_name.clone(),
|
||||||
created_at,
|
created_at: workspace.created_at.clone(),
|
||||||
revision: file_revision(&path),
|
revision: workspace.updated_at.clone(),
|
||||||
source: "workspace_identity".to_string(),
|
source: "server_db".to_string(),
|
||||||
diagnostics,
|
diagnostics: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update_workspace_metadata(
|
pub fn sanitize_workspace_display_name(value: &str) -> Result<String> {
|
||||||
workspace_root: &Path,
|
let trimmed = value.trim();
|
||||||
request: UpdateWorkspaceMetadataRequest,
|
if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 {
|
||||||
) -> Result<WorkspaceMetadataSettingsResponse> {
|
|
||||||
let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH);
|
|
||||||
let current_revision = file_revision(&path);
|
|
||||||
if request.revision != current_revision {
|
|
||||||
return Err(Error::RuntimeOperationFailed {
|
return Err(Error::RuntimeOperationFailed {
|
||||||
runtime_id: "workspace-backend".to_string(),
|
runtime_id: "workspace-backend".to_string(),
|
||||||
code: "workspace_metadata_revision_conflict".to_string(),
|
code: "workspace_display_name_invalid".to_string(),
|
||||||
message: "Workspace metadata changed before this update was applied".to_string(),
|
message: "Workspace display name must be non-empty, bounded, and must not contain control characters".to_string(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let raw = fs::read_to_string(&path)?;
|
Ok(trimmed.to_string())
|
||||||
let mut file: WorkspaceIdentityFile = toml::from_str(&raw)
|
|
||||||
.map_err(|err| Error::Config(format!("failed to parse workspace identity: {err}")))?;
|
|
||||||
let display_name = sanitize_display_name(&request.display_name)?;
|
|
||||||
file.display_name = display_name;
|
|
||||||
let encoded = toml::to_string_pretty(&file)
|
|
||||||
.map_err(|err| Error::Config(format!("failed to serialize workspace identity: {err}")))?;
|
|
||||||
fs::write(&path, encoded)?;
|
|
||||||
Ok(workspace_metadata_settings(
|
|
||||||
workspace_root,
|
|
||||||
&file.workspace_id,
|
|
||||||
&file.created_at,
|
|
||||||
&file.display_name,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> {
|
fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec<WorkspaceProfileSummary> {
|
||||||
@@ -728,17 +652,6 @@ fn collect_decodal_import_specifiers(content: &str) -> Vec<String> {
|
|||||||
specifiers
|
specifiers
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_display_name(value: &str) -> Result<String> {
|
|
||||||
let trimmed = value.trim();
|
|
||||||
if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 {
|
|
||||||
return Err(Error::RuntimeOperationFailed {
|
|
||||||
runtime_id: "workspace-backend".to_string(),
|
|
||||||
code: "workspace_display_name_invalid".to_string(),
|
|
||||||
message: "Workspace display name must be non-empty, bounded, and must not contain control characters".to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(trimmed.to_string())
|
|
||||||
}
|
|
||||||
pub fn selector_for_builtin_candidate(
|
pub fn selector_for_builtin_candidate(
|
||||||
id: &str,
|
id: &str,
|
||||||
) -> Option<worker_runtime::catalog::ProfileSelector> {
|
) -> Option<worker_runtime::catalog::ProfileSelector> {
|
||||||
@@ -753,48 +666,41 @@ pub fn selector_for_builtin_candidate(
|
|||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fn file_revision(path: &Path) -> String {
|
|
||||||
let Ok(metadata) = fs::metadata(path) else {
|
|
||||||
return "missing".to_string();
|
|
||||||
};
|
|
||||||
let modified = metadata
|
|
||||||
.modified()
|
|
||||||
.ok()
|
|
||||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
|
||||||
.map(|duration| duration.as_nanos())
|
|
||||||
.unwrap_or_default();
|
|
||||||
format!("rev:{modified}:{}", metadata.len())
|
|
||||||
}
|
|
||||||
fn diagnostic(
|
|
||||||
code: impl Into<String>,
|
|
||||||
severity: DiagnosticSeverity,
|
|
||||||
message: impl Into<String>,
|
|
||||||
) -> Diagnostic {
|
|
||||||
Diagnostic {
|
|
||||||
code: code.into(),
|
|
||||||
severity,
|
|
||||||
message: message.into(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fn sanitize_error(value: &str) -> String {
|
|
||||||
value
|
|
||||||
.split_whitespace()
|
|
||||||
.map(|token| {
|
|
||||||
if token.starts_with('/') || token.contains("/.yoi/") || token.contains(".yoi/sessions")
|
|
||||||
{
|
|
||||||
"<redacted-path>"
|
|
||||||
} else {
|
|
||||||
token
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_metadata_projects_server_database_record_without_filesystem_diagnostics() {
|
||||||
|
let workspace = WorkspaceRecord {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
owner_account_id: "owner-account".to_string(),
|
||||||
|
display_name: "Workspace A".to_string(),
|
||||||
|
state: "active".to_string(),
|
||||||
|
created_at: "2026-01-01T00:00:00Z".to_string(),
|
||||||
|
updated_at: "2026-01-02T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let settings = workspace_metadata_settings(&workspace);
|
||||||
|
|
||||||
|
assert_eq!(settings.workspace_id, workspace.workspace_id);
|
||||||
|
assert_eq!(settings.display_name, workspace.display_name);
|
||||||
|
assert_eq!(settings.created_at, workspace.created_at);
|
||||||
|
assert_eq!(settings.revision, workspace.updated_at);
|
||||||
|
assert_eq!(settings.source, "server_db");
|
||||||
|
assert!(settings.diagnostics.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_display_name_validation_is_bounded() {
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_workspace_display_name(" Workspace A ").unwrap(),
|
||||||
|
"Workspace A"
|
||||||
|
);
|
||||||
|
assert!(sanitize_workspace_display_name("\n").is_err());
|
||||||
|
assert!(sanitize_workspace_display_name(&"a".repeat(121)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
fn valid_decodal(slug: &str) -> String {
|
fn valid_decodal(slug: &str) -> String {
|
||||||
format!(r#"{{ slug = "{slug}"; model = {{ id = "gpt-5.4"; }}; }}"#)
|
format!(r#"{{ slug = "{slug}"; model = {{ id = "gpt-5.4"; }}; }}"#)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,13 +288,12 @@ impl RepositoryRegistryReader {
|
|||||||
|
|
||||||
fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary {
|
fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary {
|
||||||
let mut diagnostics = Vec::new();
|
let mut diagnostics = Vec::new();
|
||||||
if repository.source.kind == workspace_api::RepositorySourceKind::Http {
|
if crate::repository_source::is_plain_http_repository_source(&repository.source) {
|
||||||
diagnostics.push(RepositoryDiagnostic {
|
diagnostics.push(RepositoryDiagnostic {
|
||||||
severity: "warning".to_string(),
|
severity: "error".to_string(),
|
||||||
code: "repository_source_insecure_http".to_string(),
|
code: "repository_source_plain_http_unsupported".to_string(),
|
||||||
message:
|
message: "Plain HTTP Repository sources are not executable; register an HTTPS or SSH source instead."
|
||||||
"HTTP Repository source is unencrypted; prefer HTTPS or SSH when available."
|
.to_string(),
|
||||||
.to_string(),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let git = match repository.provider.as_str() {
|
let git = match repository.provider.as_str() {
|
||||||
@@ -607,6 +606,39 @@ mod tests {
|
|||||||
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
|
assert_eq!(projection.diagnostics[0].code, "repository_config_empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_plain_http_source_is_projected_as_non_executable_error() {
|
||||||
|
let source: RepositorySource = serde_json::from_value(serde_json::json!({
|
||||||
|
"kind": "http",
|
||||||
|
"uri": "http://git.example.test/team/project.git",
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let reader = RepositoryRegistryReader::new(vec![ConfiguredRepository {
|
||||||
|
id: "legacy-http".into(),
|
||||||
|
repository_key: "legacy-http".into(),
|
||||||
|
provider: "git".into(),
|
||||||
|
source_fingerprint: crate::repository_source::repository_source_fingerprint(&source),
|
||||||
|
source,
|
||||||
|
source_revision: 1,
|
||||||
|
observed_status: RepositoryObservedStatus::Unverified,
|
||||||
|
observed_at: None,
|
||||||
|
path: None,
|
||||||
|
default_selector: Some("main".into()),
|
||||||
|
}]);
|
||||||
|
|
||||||
|
let projection = reader.list();
|
||||||
|
assert_eq!(
|
||||||
|
projection.items[0].source.kind,
|
||||||
|
workspace_api::RepositorySourceKind::Invalid
|
||||||
|
);
|
||||||
|
let diagnostics = projection.items[0].diagnostics.as_ref().unwrap();
|
||||||
|
assert!(diagnostics.iter().any(|diagnostic| {
|
||||||
|
diagnostic.severity == "error"
|
||||||
|
&& diagnostic.code == "repository_source_plain_http_unsupported"
|
||||||
|
&& diagnostic.message.contains("HTTPS or SSH")
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remote_source_is_visible_but_local_provider_operations_fail_closed() {
|
fn remote_source_is_visible_but_local_provider_operations_fail_closed() {
|
||||||
let source = RepositorySource {
|
let source = RepositorySource {
|
||||||
|
|||||||
@@ -7,16 +7,19 @@ use std::sync::Arc;
|
|||||||
use chrono::{SecondsFormat, Utc};
|
use chrono::{SecondsFormat, Utc};
|
||||||
use config_source::ConfigSchemaContribution;
|
use config_source::ConfigSchemaContribution;
|
||||||
use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey};
|
use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey};
|
||||||
|
use ring::hmac;
|
||||||
use ring::rand::{SecureRandom, SystemRandom};
|
use ring::rand::{SecureRandom, SystemRandom};
|
||||||
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
use rusqlite::{OptionalExtension, TransactionBehavior, params};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use ssh_key::private::Ed25519Keypair;
|
||||||
use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey, PublicKey};
|
use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey, PublicKey};
|
||||||
use workspace_api::{
|
use workspace_api::{
|
||||||
CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest,
|
CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest,
|
||||||
DeleteRepositorySshHostTrustRequest, PutRepositorySshHostTrustRequest, RepositoryAccessMode,
|
DeleteRepositorySshHostTrustRequest, GenerateRepositorySshCredentialRequest,
|
||||||
RepositoryAccessProjection, RepositorySshAccessBinding, RepositorySshCredential,
|
PutRepositorySshHostTrustRequest, RepositoryAccessMode, RepositoryAccessProjection,
|
||||||
RepositorySshHostTrust, RotateRepositorySshCredentialRequest,
|
RepositorySshAccessBinding, RepositorySshCredential, RepositorySshHostTrust,
|
||||||
|
RepositorySshPublicKey, RotateRepositorySshCredentialRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::config_source::{
|
use crate::config_source::{
|
||||||
@@ -42,6 +45,9 @@ const MAX_NAME_BYTES: usize = 200;
|
|||||||
const MAX_IDENTIFIER_BYTES: usize = 128;
|
const MAX_IDENTIFIER_BYTES: usize = 128;
|
||||||
const MASTER_KEY_BYTES: usize = 32;
|
const MASTER_KEY_BYTES: usize = 32;
|
||||||
const NONCE_BYTES: usize = 12;
|
const NONCE_BYTES: usize = 12;
|
||||||
|
pub const WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID: &str = "workspace-default";
|
||||||
|
const WORKSPACE_DEFAULT_REPOSITORY_SSH_OPERATION_ID: &str = "workspace-default-repository-ssh-v1";
|
||||||
|
const WORKSPACE_DEFAULT_REPOSITORY_SSH_NAME: &str = "Workspace default SSH key";
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct RepositoryAccessConfigSchemaProvider;
|
pub struct RepositoryAccessConfigSchemaProvider;
|
||||||
@@ -130,6 +136,55 @@ pub fn project_repository_access_state(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn validate_repository_access_source(
|
||||||
|
repository_key: &str,
|
||||||
|
source: &workspace_api::RepositorySource,
|
||||||
|
) -> Result<()> {
|
||||||
|
if crate::repository_source::is_plain_http_repository_source(source) {
|
||||||
|
return Err(Error::InvalidInput(format!(
|
||||||
|
"repository_source_plain_http_unsupported: Repository `{repository_key}` uses unsupported plain HTTP; register an HTTPS or SSH source instead"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn repository_ssh_endpoint(
|
||||||
|
repository_key: &str,
|
||||||
|
repository_uri: &str,
|
||||||
|
) -> Result<Option<(String, u16)>> {
|
||||||
|
if !repository_uri.contains("://") {
|
||||||
|
if let Some((identity, path)) = repository_uri.split_once(':')
|
||||||
|
&& !path.is_empty()
|
||||||
|
&& let Some((_, hostname)) = identity.rsplit_once('@')
|
||||||
|
&& !hostname.is_empty()
|
||||||
|
{
|
||||||
|
return Ok(Some((hostname.to_ascii_lowercase(), 22)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let parsed = url::Url::parse(repository_uri).map_err(|error| {
|
||||||
|
Error::InvalidInput(format!(
|
||||||
|
"Repository `{repository_key}` has invalid SSH URI: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
if parsed.scheme() != "ssh" {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
if parsed.username().is_empty() || parsed.password().is_some() {
|
||||||
|
return Err(Error::InvalidInput(format!(
|
||||||
|
"Repository `{repository_key}` must use ssh://user@host[:port]/path without embedded credentials"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let hostname = parsed.host_str().ok_or_else(|| {
|
||||||
|
Error::InvalidInput(format!(
|
||||||
|
"Repository `{repository_key}` SSH URI has no hostname"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
Ok(Some((
|
||||||
|
hostname.to_ascii_lowercase(),
|
||||||
|
parsed.port().unwrap_or(22),
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
fn project_repository_access_evaluation(
|
fn project_repository_access_evaluation(
|
||||||
store: &dyn ControlPlaneStore,
|
store: &dyn ControlPlaneStore,
|
||||||
secrets: &RepositorySecretService,
|
secrets: &RepositorySecretService,
|
||||||
@@ -145,6 +200,9 @@ fn project_repository_access_evaluation(
|
|||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
Error::InvalidInput(format!("invalid Repository access config: {error}"))
|
Error::InvalidInput(format!("invalid Repository access config: {error}"))
|
||||||
})?;
|
})?;
|
||||||
|
if !config.repository_access.is_empty() {
|
||||||
|
secrets.ensure_workspace_default_credential(workspace_id)?;
|
||||||
|
}
|
||||||
let mut bindings = Vec::with_capacity(config.repository_access.len());
|
let mut bindings = Vec::with_capacity(config.repository_access.len());
|
||||||
for (repository_key, access) in config.repository_access {
|
for (repository_key, access) in config.repository_access {
|
||||||
workspace_api::validate_repository_key(&repository_key)
|
workspace_api::validate_repository_key(&repository_key)
|
||||||
@@ -154,6 +212,7 @@ fn project_repository_access_evaluation(
|
|||||||
let repository = store
|
let repository = store
|
||||||
.get_repository_by_key(workspace_id, &repository_key)?
|
.get_repository_by_key(workspace_id, &repository_key)?
|
||||||
.ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?;
|
.ok_or_else(|| Error::InvalidInput(format!("unknown Repository `{repository_key}`")))?;
|
||||||
|
validate_repository_access_source(&repository_key, &repository.source)?;
|
||||||
if repository.source.kind != workspace_api::RepositorySourceKind::Ssh {
|
if repository.source.kind != workspace_api::RepositorySourceKind::Ssh {
|
||||||
return Err(Error::InvalidInput(format!(
|
return Err(Error::InvalidInput(format!(
|
||||||
"Repository `{repository_key}` is not an ssh:// Repository"
|
"Repository `{repository_key}` is not an ssh:// Repository"
|
||||||
@@ -181,22 +240,14 @@ fn project_repository_access_evaluation(
|
|||||||
access.ssh.host_trust
|
access.ssh.host_trust
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let uri = url::Url::parse(&repository.source.uri).map_err(|_| {
|
let (hostname, port) =
|
||||||
Error::InvalidInput(format!(
|
repository_ssh_endpoint(repository_key.as_str(), &repository.source.uri)?.ok_or_else(
|
||||||
"Repository `{repository_key}` has an invalid SSH URI"
|
|| {
|
||||||
))
|
Error::InvalidInput(format!(
|
||||||
})?;
|
"Repository `{repository_key}` must use an SSH source"
|
||||||
if uri.scheme() != "ssh" || uri.username().is_empty() || uri.password().is_some() {
|
))
|
||||||
return Err(Error::InvalidInput(format!(
|
},
|
||||||
"Repository `{repository_key}` must use ssh://user@host[:port]/path without credentials"
|
)?;
|
||||||
)));
|
|
||||||
}
|
|
||||||
let hostname = uri.host_str().ok_or_else(|| {
|
|
||||||
Error::InvalidInput(format!(
|
|
||||||
"Repository `{repository_key}` SSH URI has no hostname"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let port = uri.port().unwrap_or(22);
|
|
||||||
if hostname != host_trust.hostname || port != host_trust.port {
|
if hostname != host_trust.hostname || port != host_trust.port {
|
||||||
return Err(Error::InvalidInput(format!(
|
return Err(Error::InvalidInput(format!(
|
||||||
"Repository `{repository_key}` SSH host does not match host trust `{}`",
|
"Repository `{repository_key}` SSH host does not match host trust `{}`",
|
||||||
@@ -245,6 +296,152 @@ impl RepositorySecretService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn generated_ed25519_private_key(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
operation_id: &str,
|
||||||
|
credential_id: &str,
|
||||||
|
intent: &str,
|
||||||
|
) -> Result<String> {
|
||||||
|
let master_key = self.master_key.as_ref().ok_or_else(|| {
|
||||||
|
Error::Store("Repository secret encryption authority is unavailable".to_string())
|
||||||
|
})?;
|
||||||
|
let key = hmac::Key::new(hmac::HMAC_SHA256, master_key.as_slice());
|
||||||
|
let context = format!(
|
||||||
|
"yoi/repository-ssh-key/v1\0{workspace_id}\0{operation_id}\0{credential_id}\0{intent}"
|
||||||
|
);
|
||||||
|
let seed = hmac::sign(&key, context.as_bytes());
|
||||||
|
PrivateKey::from(Ed25519Keypair::from_seed(
|
||||||
|
seed.as_ref().try_into().map_err(|_| {
|
||||||
|
Error::Store("generated SSH Ed25519 seed had an invalid length".to_string())
|
||||||
|
})?,
|
||||||
|
))
|
||||||
|
.to_openssh(LineEnding::LF)
|
||||||
|
.map(|key| key.to_string())
|
||||||
|
.map_err(|err| Error::Store(format!("failed to encode generated SSH key: {err}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate_credential(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
request: GenerateRepositorySshCredentialRequest,
|
||||||
|
actor_account_id: &str,
|
||||||
|
) -> Result<RepositorySshCredential> {
|
||||||
|
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
|
||||||
|
let credential_id = validate_identifier("credential_id", &request.credential_id)?;
|
||||||
|
let name = normalize_name(&request.name)?;
|
||||||
|
let private_key = self.generated_ed25519_private_key(
|
||||||
|
workspace_id,
|
||||||
|
&operation_id,
|
||||||
|
&credential_id,
|
||||||
|
&format!("create\0{name}"),
|
||||||
|
)?;
|
||||||
|
self.create_credential(
|
||||||
|
workspace_id,
|
||||||
|
CreateRepositorySshCredentialRequest {
|
||||||
|
operation_id,
|
||||||
|
credential_id,
|
||||||
|
name,
|
||||||
|
private_key,
|
||||||
|
passphrase: None,
|
||||||
|
},
|
||||||
|
actor_account_id,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ensure_workspace_default_credential(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
) -> Result<RepositorySshCredential> {
|
||||||
|
if let Some(credential) = self.store.with_conn(|conn| {
|
||||||
|
read_credential(
|
||||||
|
conn,
|
||||||
|
workspace_id,
|
||||||
|
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||||
|
)
|
||||||
|
})? {
|
||||||
|
return Ok(credential);
|
||||||
|
}
|
||||||
|
self.generate_credential(
|
||||||
|
workspace_id,
|
||||||
|
GenerateRepositorySshCredentialRequest {
|
||||||
|
operation_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_OPERATION_ID.to_string(),
|
||||||
|
credential_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID.to_string(),
|
||||||
|
name: WORKSPACE_DEFAULT_REPOSITORY_SSH_NAME.to_string(),
|
||||||
|
},
|
||||||
|
"workspace-system",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn credential_public_key(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
credential_id: &str,
|
||||||
|
) -> Result<Option<RepositorySshPublicKey>> {
|
||||||
|
let credential_id = validate_identifier("credential_id", credential_id)?;
|
||||||
|
let Some((credential, private_secret, passphrase_secret)) =
|
||||||
|
self.store.with_conn(|conn| {
|
||||||
|
let Some(credential) = read_credential(conn, workspace_id, &credential_id)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let private_secret = read_sealed_secret(
|
||||||
|
conn,
|
||||||
|
workspace_id,
|
||||||
|
&credential_id,
|
||||||
|
credential.current_revision,
|
||||||
|
"private_key",
|
||||||
|
)?
|
||||||
|
.ok_or_else(|| Error::Store("credential private key is missing".to_string()))?;
|
||||||
|
let passphrase_secret = read_sealed_secret(
|
||||||
|
conn,
|
||||||
|
workspace_id,
|
||||||
|
&credential_id,
|
||||||
|
credential.current_revision,
|
||||||
|
"passphrase",
|
||||||
|
)?;
|
||||||
|
Ok(Some((credential, private_secret, passphrase_secret)))
|
||||||
|
})?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let private_key = zeroize::Zeroizing::new(self.unseal(
|
||||||
|
workspace_id,
|
||||||
|
&credential_id,
|
||||||
|
credential.current_revision,
|
||||||
|
"private_key",
|
||||||
|
private_secret,
|
||||||
|
)?);
|
||||||
|
let passphrase = passphrase_secret
|
||||||
|
.map(|secret| {
|
||||||
|
self.unseal(
|
||||||
|
workspace_id,
|
||||||
|
&credential_id,
|
||||||
|
credential.current_revision,
|
||||||
|
"passphrase",
|
||||||
|
secret,
|
||||||
|
)
|
||||||
|
.map(zeroize::Zeroizing::new)
|
||||||
|
})
|
||||||
|
.transpose()?;
|
||||||
|
let private_key = std::str::from_utf8(private_key.as_slice())
|
||||||
|
.map_err(|_| Error::Store("credential private key is not UTF-8".to_string()))?;
|
||||||
|
let passphrase = passphrase
|
||||||
|
.as_deref()
|
||||||
|
.map(|value| std::str::from_utf8(value.as_slice()))
|
||||||
|
.transpose()
|
||||||
|
.map_err(|_| Error::Store("credential passphrase is not UTF-8".to_string()))?;
|
||||||
|
let parsed = parse_private_key(private_key, passphrase).map_err(|err| {
|
||||||
|
Error::Store(format!("stored credential private key is invalid: {err}"))
|
||||||
|
})?;
|
||||||
|
Ok(Some(RepositorySshPublicKey {
|
||||||
|
credential_id,
|
||||||
|
current_revision: credential.current_revision,
|
||||||
|
public_key_algorithm: parsed.algorithm,
|
||||||
|
public_key_fingerprint: parsed.fingerprint,
|
||||||
|
public_key: parsed.public_key,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_credential(
|
pub fn create_credential(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -384,6 +581,11 @@ impl RepositorySecretService {
|
|||||||
actor_account_id: &str,
|
actor_account_id: &str,
|
||||||
) -> Result<RepositorySshCredential> {
|
) -> Result<RepositorySshCredential> {
|
||||||
let credential_id = validate_identifier("credential_id", credential_id)?;
|
let credential_id = validate_identifier("credential_id", credential_id)?;
|
||||||
|
if credential_id == WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID {
|
||||||
|
return Err(Error::WorkspaceConfigConflict(
|
||||||
|
"Workspace default SSH credential is immutable".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
|
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
|
||||||
let parsed = parse_private_key(&request.private_key, request.passphrase.as_deref())?;
|
let parsed = parse_private_key(&request.private_key, request.passphrase.as_deref())?;
|
||||||
let next_revision = request
|
let next_revision = request
|
||||||
@@ -529,6 +731,11 @@ impl RepositorySecretService {
|
|||||||
projection: &RepositoryAccessProjection,
|
projection: &RepositoryAccessProjection,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let credential_id = validate_identifier("credential_id", credential_id)?;
|
let credential_id = validate_identifier("credential_id", credential_id)?;
|
||||||
|
if credential_id == WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID {
|
||||||
|
return Err(Error::WorkspaceConfigConflict(
|
||||||
|
"Workspace default SSH credential is immutable".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
|
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
|
||||||
let references = credential_references(projection, &credential_id);
|
let references = credential_references(projection, &credential_id);
|
||||||
if !references.is_empty() {
|
if !references.is_empty() {
|
||||||
@@ -555,6 +762,20 @@ impl RepositorySecretService {
|
|||||||
"credential `{credential_id}` revision changed"
|
"credential `{credential_id}` revision changed"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
let retained_by_workdir_create: bool = tx.query_row(
|
||||||
|
r#"SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM workdir_create_credential_revision_retentions
|
||||||
|
WHERE workspace_id = ?1 AND credential_id = ?2
|
||||||
|
)"#,
|
||||||
|
params![workspace_id, credential_id],
|
||||||
|
|row| row.get(0),
|
||||||
|
)?;
|
||||||
|
if retained_by_workdir_create {
|
||||||
|
return Err(Error::RepositoryConflict(format!(
|
||||||
|
"credential `{credential_id}` is retained by a retryable Workdir create operation"
|
||||||
|
)));
|
||||||
|
}
|
||||||
insert_audit(&tx, workspace_id, "credential_deleted", &credential_id, current.current_revision, actor_account_id, &now)?;
|
insert_audit(&tx, workspace_id, "credential_deleted", &credential_id, current.current_revision, actor_account_id, &now)?;
|
||||||
let deleted = tx.execute(
|
let deleted = tx.execute(
|
||||||
"DELETE FROM repository_ssh_credentials WHERE workspace_id = ?1 AND credential_id = ?2 AND current_revision = ?3",
|
"DELETE FROM repository_ssh_credentials WHERE workspace_id = ?1 AND credential_id = ?2 AND current_revision = ?3",
|
||||||
@@ -839,6 +1060,73 @@ impl RepositorySecretService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn host_trusts_for_endpoint(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
hostname: &str,
|
||||||
|
port: u16,
|
||||||
|
) -> Result<Vec<RepositorySshHostTrust>> {
|
||||||
|
self.store.with_conn(|conn| {
|
||||||
|
let mut statement = conn.prepare(
|
||||||
|
r#"SELECT workspace_id, host_trust_id, hostname, port, key_algorithm,
|
||||||
|
host_key, fingerprint, current_revision, created_at, updated_at
|
||||||
|
FROM repository_ssh_host_trusts
|
||||||
|
WHERE workspace_id = ?1 AND lower(hostname) = lower(?2) AND port = ?3
|
||||||
|
ORDER BY host_trust_id"#,
|
||||||
|
)?;
|
||||||
|
statement
|
||||||
|
.query_map(
|
||||||
|
params![workspace_id, hostname, i64::from(port)],
|
||||||
|
read_host_trust_row,
|
||||||
|
)?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn automatic_host_trust_id(hostname: &str, port: u16) -> String {
|
||||||
|
let normalized = hostname
|
||||||
|
.chars()
|
||||||
|
.map(|character| {
|
||||||
|
if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
|
||||||
|
character.to_ascii_lowercase()
|
||||||
|
} else {
|
||||||
|
'-'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.take(96)
|
||||||
|
.collect::<String>();
|
||||||
|
format!("tofu-{normalized}-{port}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn default_ssh_binding_for_repository(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
repository_key: &str,
|
||||||
|
repository_uri: &str,
|
||||||
|
) -> Result<Option<RepositorySshAccessBinding>> {
|
||||||
|
let Some((hostname, port)) = repository_ssh_endpoint(repository_key, repository_uri)?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let matches = self.host_trusts_for_endpoint(workspace_id, &hostname, port)?;
|
||||||
|
let Some(host_trust) = matches.first() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if matches.len() > 1 {
|
||||||
|
return Err(Error::InvalidInput(format!(
|
||||||
|
"Repository `{repository_key}` matches multiple SSH host trusts for {hostname}:{port}; configure an explicit Repository access binding"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
self.ensure_workspace_default_credential(workspace_id)?;
|
||||||
|
Ok(Some(RepositorySshAccessBinding {
|
||||||
|
repository_key: repository_key.to_string(),
|
||||||
|
credential_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID.to_string(),
|
||||||
|
host_trust_id: host_trust.host_trust_id.clone(),
|
||||||
|
access: RepositoryAccessMode::ReadOnly,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn lease_ssh_materialization_access(
|
pub fn lease_ssh_materialization_access(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -1056,6 +1344,7 @@ impl RepositorySecretService {
|
|||||||
struct ParsedKey {
|
struct ParsedKey {
|
||||||
algorithm: String,
|
algorithm: String,
|
||||||
fingerprint: String,
|
fingerprint: String,
|
||||||
|
public_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result<ParsedKey> {
|
fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result<ParsedKey> {
|
||||||
@@ -1092,6 +1381,9 @@ fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result<Pars
|
|||||||
Ok(ParsedKey {
|
Ok(ParsedKey {
|
||||||
algorithm: public_key.algorithm().to_string(),
|
algorithm: public_key.algorithm().to_string(),
|
||||||
fingerprint: public_key.fingerprint(HashAlg::Sha256).to_string(),
|
fingerprint: public_key.fingerprint(HashAlg::Sha256).to_string(),
|
||||||
|
public_key: public_key.to_openssh().map_err(|err| {
|
||||||
|
Error::Store(format!("failed to encode Repository SSH public key: {err}"))
|
||||||
|
})?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1691,6 +1983,30 @@ mod tests {
|
|||||||
assert!(!contribution.source.contains("secret_ref"));
|
assert!(!contribution.source.contains("secret_ref"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_config_projection_rejects_legacy_plain_http_repository_source() {
|
||||||
|
let source: RepositorySource = serde_json::from_value(serde_json::json!({
|
||||||
|
"kind": "http",
|
||||||
|
"uri": "http://git.example.test/team/project.git",
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let error = validate_repository_access_source("remote", &source).unwrap_err();
|
||||||
|
assert!(error.to_string().contains("unsupported plain HTTP"));
|
||||||
|
assert!(error.to_string().contains("HTTPS or SSH"));
|
||||||
|
|
||||||
|
let mismatched = workspace_api::RepositorySource {
|
||||||
|
kind: workspace_api::RepositorySourceKind::Https,
|
||||||
|
uri: "http://git.example.test/team/project.git".to_string(),
|
||||||
|
};
|
||||||
|
let error = validate_repository_access_source("remote", &mismatched).unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("repository_source_plain_http_unsupported")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn master_key_is_external_and_stable() {
|
fn master_key_is_external_and_stable() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -1708,6 +2024,102 @@ mod tests {
|
|||||||
assert!(!error.contains(secret));
|
assert!(!error.contains(secret));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workspace_default_credential_is_generated_once_and_immutable() {
|
||||||
|
let (_dir, _store, service) = test_service();
|
||||||
|
|
||||||
|
let created = service
|
||||||
|
.ensure_workspace_default_credential("workspace-a")
|
||||||
|
.unwrap();
|
||||||
|
let replayed = service
|
||||||
|
.ensure_workspace_default_credential("workspace-a")
|
||||||
|
.unwrap();
|
||||||
|
let public_key = service
|
||||||
|
.credential_public_key(
|
||||||
|
"workspace-a",
|
||||||
|
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(created, replayed);
|
||||||
|
assert_eq!(created.current_revision, 1);
|
||||||
|
assert_eq!(
|
||||||
|
public_key.public_key_fingerprint,
|
||||||
|
created.public_key_fingerprint
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.rotate_credential(
|
||||||
|
"workspace-a",
|
||||||
|
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||||
|
RotateRepositorySshCredentialRequest {
|
||||||
|
operation_id: "rotate-default".to_string(),
|
||||||
|
expected_revision: 1,
|
||||||
|
private_key: test_private_key(12).0,
|
||||||
|
passphrase: None,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.delete_credential(
|
||||||
|
"workspace-a",
|
||||||
|
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||||
|
DeleteRepositorySshCredentialRequest {
|
||||||
|
operation_id: "delete-default".to_string(),
|
||||||
|
expected_revision: 1,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
&RepositoryAccessProjection {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
config_revision: 1,
|
||||||
|
projection_digest: "sha256:empty".to_string(),
|
||||||
|
bindings: Vec::new(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn generated_credential_is_replayable_and_exposes_only_its_public_key() {
|
||||||
|
let (_dir, _store, service) = test_service();
|
||||||
|
let request = GenerateRepositorySshCredentialRequest {
|
||||||
|
operation_id: "generate-one".to_string(),
|
||||||
|
credential_id: "workspace-key".to_string(),
|
||||||
|
name: "Workspace key".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let created = service
|
||||||
|
.generate_credential("workspace-a", request.clone(), "owner-a")
|
||||||
|
.unwrap();
|
||||||
|
let replayed = service
|
||||||
|
.generate_credential("workspace-a", request, "owner-a")
|
||||||
|
.unwrap();
|
||||||
|
let public_key = service
|
||||||
|
.credential_public_key("workspace-a", "workspace-key")
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(replayed, created);
|
||||||
|
assert_eq!(public_key.current_revision, created.current_revision);
|
||||||
|
assert_eq!(
|
||||||
|
public_key.public_key_fingerprint,
|
||||||
|
created.public_key_fingerprint
|
||||||
|
);
|
||||||
|
assert!(public_key.public_key.starts_with("ssh-ed25519 "));
|
||||||
|
assert!(!public_key.public_key.contains("PRIVATE KEY"));
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.credential_public_key("workspace-b", "workspace-key")
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn credential_create_rotate_replay_and_cross_workspace_scope_keep_secrets_write_only() {
|
fn credential_create_rotate_replay_and_cross_workspace_scope_keep_secrets_write_only() {
|
||||||
let (_dir, store, service) = test_service();
|
let (_dir, store, service) = test_service();
|
||||||
@@ -1964,6 +2376,194 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_binding_resolves_unique_host_trust_for_url_and_scp_ssh_sources() {
|
||||||
|
let (_dir, _store, service) = test_service();
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.default_ssh_binding_for_repository(
|
||||||
|
"workspace-a",
|
||||||
|
"main",
|
||||||
|
"git@example.test:org/main.git",
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
let (_, host_key) = test_private_key(10);
|
||||||
|
service
|
||||||
|
.put_host_trust(
|
||||||
|
"workspace-a",
|
||||||
|
PutRepositorySshHostTrustRequest {
|
||||||
|
operation_id: "host-default".to_string(),
|
||||||
|
host_trust_id: "example".to_string(),
|
||||||
|
hostname: "example.test".to_string(),
|
||||||
|
port: 22,
|
||||||
|
host_key,
|
||||||
|
expected_revision: None,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
for uri in [
|
||||||
|
"ssh://git@example.test/org/main.git",
|
||||||
|
"git@example.test:org/main.git",
|
||||||
|
] {
|
||||||
|
let binding = service
|
||||||
|
.default_ssh_binding_for_repository("workspace-a", "main", uri)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
binding.credential_id,
|
||||||
|
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID
|
||||||
|
);
|
||||||
|
assert_eq!(binding.host_trust_id, "example");
|
||||||
|
assert_eq!(binding.access, RepositoryAccessMode::ReadOnly);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.credential_public_key(
|
||||||
|
"workspace-a",
|
||||||
|
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
|
||||||
|
let (_, second_host_key) = test_private_key(11);
|
||||||
|
service
|
||||||
|
.put_host_trust(
|
||||||
|
"workspace-a",
|
||||||
|
PutRepositorySshHostTrustRequest {
|
||||||
|
operation_id: "host-default-second".to_string(),
|
||||||
|
host_trust_id: "example-second".to_string(),
|
||||||
|
hostname: "example.test".to_string(),
|
||||||
|
port: 22,
|
||||||
|
host_key: second_host_key,
|
||||||
|
expected_revision: None,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
service
|
||||||
|
.default_ssh_binding_for_repository(
|
||||||
|
"workspace-a",
|
||||||
|
"main",
|
||||||
|
"git@example.test:org/main.git",
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retryable_workdir_create_retains_candidate_revision_until_success() {
|
||||||
|
let (_dir, store, service) = test_service();
|
||||||
|
let (private_key, _) = test_private_key(13);
|
||||||
|
service
|
||||||
|
.create_credential(
|
||||||
|
"workspace-a",
|
||||||
|
CreateRepositorySshCredentialRequest {
|
||||||
|
operation_id: "create-retained".to_string(),
|
||||||
|
credential_id: "retained-deploy".to_string(),
|
||||||
|
name: "Retained deploy".to_string(),
|
||||||
|
private_key,
|
||||||
|
passphrase: None,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let operation = crate::store::WorkdirCreateOperationRecord {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
operation_id: "create-workdir-retained".to_string(),
|
||||||
|
request_fingerprint: "sha256:request".to_string(),
|
||||||
|
repository_id: "repo-a".to_string(),
|
||||||
|
selector: Some("develop".to_string()),
|
||||||
|
requested_runtime_id: Some("runtime-a".to_string()),
|
||||||
|
resolved_runtime_id: "runtime-a".to_string(),
|
||||||
|
config_revision: 1,
|
||||||
|
config_projection_digest: "sha256:projection".to_string(),
|
||||||
|
source_kind: Some("ssh".to_string()),
|
||||||
|
source_uri: Some("ssh://git@example.test/org/main.git".to_string()),
|
||||||
|
source_revision: Some(1),
|
||||||
|
source_fingerprint: Some("sha256:source".to_string()),
|
||||||
|
credential_id: None,
|
||||||
|
credential_revision: None,
|
||||||
|
host_trust_id: None,
|
||||||
|
host_trust_revision: None,
|
||||||
|
repository_access_mode: None,
|
||||||
|
credential_candidates: Vec::new(),
|
||||||
|
working_directory_id: "workdir-retained".to_string(),
|
||||||
|
state: "pending".to_string(),
|
||||||
|
failure: None,
|
||||||
|
created_at: "2026-08-24T00:00:00Z".to_string(),
|
||||||
|
updated_at: "2026-08-24T00:00:00Z".to_string(),
|
||||||
|
};
|
||||||
|
store.reserve_workdir_create_operation(&operation).unwrap();
|
||||||
|
let candidates = vec![crate::store::WorkdirCreateCredentialCandidate {
|
||||||
|
role: crate::store::WorkdirCreateCredentialCandidateRole::Primary,
|
||||||
|
credential_id: "retained-deploy".to_string(),
|
||||||
|
credential_revision: 1,
|
||||||
|
}];
|
||||||
|
store
|
||||||
|
.bind_workdir_create_repository_access(
|
||||||
|
"workspace-a",
|
||||||
|
"create-workdir-retained",
|
||||||
|
"sha256:request",
|
||||||
|
"retained-deploy",
|
||||||
|
1,
|
||||||
|
"host-a",
|
||||||
|
1,
|
||||||
|
"read_only",
|
||||||
|
&candidates,
|
||||||
|
"2026-08-24T00:00:01Z",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let projection = RepositoryAccessProjection {
|
||||||
|
workspace_id: "workspace-a".to_string(),
|
||||||
|
config_revision: 1,
|
||||||
|
projection_digest: "sha256:empty".to_string(),
|
||||||
|
bindings: Vec::new(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let retained = service
|
||||||
|
.delete_credential(
|
||||||
|
"workspace-a",
|
||||||
|
"retained-deploy",
|
||||||
|
DeleteRepositorySshCredentialRequest {
|
||||||
|
operation_id: "delete-retained".to_string(),
|
||||||
|
expected_revision: 1,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
&projection,
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(retained, Error::RepositoryConflict(_)));
|
||||||
|
|
||||||
|
store
|
||||||
|
.finish_workdir_create_operation(
|
||||||
|
"workspace-a",
|
||||||
|
"create-workdir-retained",
|
||||||
|
"sha256:request",
|
||||||
|
true,
|
||||||
|
None,
|
||||||
|
"2026-08-24T00:00:02Z",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
service
|
||||||
|
.delete_credential(
|
||||||
|
"workspace-a",
|
||||||
|
"retained-deploy",
|
||||||
|
DeleteRepositorySshCredentialRequest {
|
||||||
|
operation_id: "delete-released".to_string(),
|
||||||
|
expected_revision: 1,
|
||||||
|
},
|
||||||
|
"owner-a",
|
||||||
|
&projection,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn referenced_resources_cannot_be_deleted() {
|
fn referenced_resources_cannot_be_deleted() {
|
||||||
let (_dir, _store, service) = test_service();
|
let (_dir, _store, service) = test_service();
|
||||||
|
|||||||
@@ -76,18 +76,19 @@ pub fn parse_repository_source(value: &str) -> Result<RepositorySource> {
|
|||||||
require_remote_host_and_path(&parsed)?;
|
require_remote_host_and_path(&parsed)?;
|
||||||
RepositorySourceKind::Ssh
|
RepositorySourceKind::Ssh
|
||||||
}
|
}
|
||||||
"http" | "https" => {
|
"http" => {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"repository_source_plain_http_unsupported: plain HTTP Repository sources are not supported; use HTTPS or SSH".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
"https" => {
|
||||||
if !parsed.username().is_empty() {
|
if !parsed.username().is_empty() {
|
||||||
return Err(Error::InvalidInput(
|
return Err(Error::InvalidInput(
|
||||||
"HTTP repository URI must not contain user information".to_string(),
|
"HTTPS repository URI must not contain user information".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
require_remote_host_and_path(&parsed)?;
|
require_remote_host_and_path(&parsed)?;
|
||||||
if parsed.scheme() == "http" {
|
RepositorySourceKind::Https
|
||||||
RepositorySourceKind::Http
|
|
||||||
} else {
|
|
||||||
RepositorySourceKind::Https
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
scheme => {
|
scheme => {
|
||||||
return Err(Error::InvalidInput(format!(
|
return Err(Error::InvalidInput(format!(
|
||||||
@@ -111,6 +112,10 @@ pub fn classify_legacy_repository_source(value: &str) -> RepositorySource {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn is_plain_http_repository_source(source: &RepositorySource) -> bool {
|
||||||
|
Url::parse(&source.uri).is_ok_and(|url| url.scheme() == "http")
|
||||||
|
}
|
||||||
|
|
||||||
pub fn repository_source_fingerprint(source: &RepositorySource) -> String {
|
pub fn repository_source_fingerprint(source: &RepositorySource) -> String {
|
||||||
let payload = serde_json::to_vec(source).expect("Repository source serializes");
|
let payload = serde_json::to_vec(source).expect("Repository source serializes");
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
@@ -172,7 +177,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_local_file_ssh_http_and_https_sources_without_io() {
|
fn parses_local_file_ssh_and_https_sources_without_io() {
|
||||||
let cases = [
|
let cases = [
|
||||||
("/runtime/repos/project", RepositorySourceKind::LocalPath),
|
("/runtime/repos/project", RepositorySourceKind::LocalPath),
|
||||||
("file:///runtime/repos/project", RepositorySourceKind::File),
|
("file:///runtime/repos/project", RepositorySourceKind::File),
|
||||||
@@ -184,10 +189,6 @@ mod tests {
|
|||||||
"git@example.test:org/project.git",
|
"git@example.test:org/project.git",
|
||||||
RepositorySourceKind::Ssh,
|
RepositorySourceKind::Ssh,
|
||||||
),
|
),
|
||||||
(
|
|
||||||
"http://git.test/org/project.git",
|
|
||||||
RepositorySourceKind::Http,
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
"https://git.test/org/project.git",
|
"https://git.test/org/project.git",
|
||||||
RepositorySourceKind::Https,
|
RepositorySourceKind::Https,
|
||||||
@@ -198,6 +199,26 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_plain_http_with_secure_transport_guidance() {
|
||||||
|
for source in [
|
||||||
|
"http://git.test/org/project.git",
|
||||||
|
"http://localhost/org/project.git",
|
||||||
|
"http://127.0.0.1/org/project.git",
|
||||||
|
] {
|
||||||
|
let error = parse_repository_source(source).expect_err("plain HTTP must fail closed");
|
||||||
|
assert!(error.to_string().contains("plain HTTP Repository sources"));
|
||||||
|
assert!(error.to_string().contains("HTTPS or SSH"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_plain_http_is_preserved_only_as_invalid_evidence() {
|
||||||
|
let source = classify_legacy_repository_source("http://git.test/org/project.git");
|
||||||
|
assert_eq!(source.kind, RepositorySourceKind::Invalid);
|
||||||
|
assert_eq!(source.uri, "http://git.test/org/project.git");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_relative_unsupported_and_credential_bearing_sources() {
|
fn rejects_relative_unsupported_and_credential_bearing_sources() {
|
||||||
for source in [
|
for source in [
|
||||||
|
|||||||
@@ -397,7 +397,13 @@ mod tests {
|
|||||||
"1",
|
"1",
|
||||||
i64::MAX,
|
i64::MAX,
|
||||||
RepositorySshAccessSecret {
|
RepositorySshAccessSecret {
|
||||||
private_key: "private-key-bytes".to_string(),
|
credential_candidates: vec![
|
||||||
|
worker_runtime::resource::RepositorySshAccessSecretCandidate {
|
||||||
|
credential_id: "credential-test".to_string(),
|
||||||
|
credential_revision: 1,
|
||||||
|
private_key: "private-key-bytes".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
known_hosts_entry: "known-hosts-entry".to_string(),
|
known_hosts_entry: "known-hosts-entry".to_string(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -419,7 +425,10 @@ mod tests {
|
|||||||
assert!(!debug.contains("private-key-bytes"));
|
assert!(!debug.contains("private-key-bytes"));
|
||||||
assert!(debug.contains("REDACTED"));
|
assert!(debug.contains("REDACTED"));
|
||||||
let secret: RepositorySshAccessSecret = serde_json::from_slice(&response.bytes).unwrap();
|
let secret: RepositorySshAccessSecret = serde_json::from_slice(&response.bytes).unwrap();
|
||||||
assert_eq!(secret.private_key, "private-key-bytes");
|
assert_eq!(
|
||||||
|
secret.credential_candidates[0].private_key,
|
||||||
|
"private-key-bytes"
|
||||||
|
);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
broker.fetch_resource(request(handle, "runtime-test", None)),
|
broker.fetch_resource(request(handle, "runtime-test", None)),
|
||||||
Err(BackendResourceError::MissingResource)
|
Err(BackendResourceError::MissingResource)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user