64 Commits
Author SHA1 Message Date
Hare 016dbd7cb1 fix: preserve worker run generations across restore 2026-09-15 01:17:30 +09:00
Hare 7210d3c202 fix: reject failed worker restores before attach 2026-09-15 00:47:12 +09:00
Hare 572204b49a fix: resolve CLI connections before async runtime 2026-09-15 00:28:25 +09:00
Hare 86dd67a24c fix: skip stopped worker protocol subscriptions 2026-09-14 23:31:14 +09:00
Hare beeba1fdfc fix: migrate legacy worker snapshots 2026-09-14 22:28:01 +09:00
Hare 41b7b289d0 fix: serialize Workdir lease admission with writes 2026-09-14 20:32:21 +09:00
Hare 24237249d1 Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy 2026-09-14 20:05:34 +09:00
Hare e448073b98 fix: fence resolved Workdir lease aliases 2026-09-14 20:05:27 +09:00
Hare c08152d069 fix: share workspace creation wire contract 2026-09-14 19:45:18 +09:00
Hare 3995561220 Merge remote-tracking branch 'refs/remotes/origin/develop' into work/T-468-remove-local-workspace-authority 2026-09-14 19:26:44 +09:00
Hare aea51caeb4 fix: resolve workspaces through backend repositories 2026-09-14 19:24:44 +09:00
Hare 3eca380bd8 Merge remote-tracking branch 'origin/develop' into work/T-604-workdir-symlink-policy 2026-09-14 19:12:17 +09:00
Hare 8a3e06bc81 feat: add selective Workdir symlink policies 2026-09-14 19:09:12 +09:00
Hare c4274c42cb fix: align plugin help with offline commands 2026-09-14 18:52:00 +09:00
Hare a61ad15767 refactor: remove ambient plugin authority 2026-09-14 18:40:42 +09:00
Hare 7f1e374fd7 fix: remove repository-local workspace selection 2026-09-14 18:31:04 +09:00
Hare e3f5445a02 fix: move workspace metadata authority to server database 2026-09-14 18:31:04 +09:00
Hare d2cb50d081 fix: fence runtime removal races and retries 2026-09-13 02:54:01 +09:00
Hare 6c609808c9 fix: use authoritative runtime removal result 2026-09-13 02:30:24 +09:00
Hare 2d4c7b383a feat: add guarded runtime removal operation 2026-09-13 02:30:15 +09:00
Hare c21ed7dff2 fix: preserve credential candidates across Workdir retries 2026-09-13 01:24:26 +09:00
Hare 448e392a0e fix: persist Workdir credential candidate snapshots 2026-09-13 01:14:39 +09:00
Hare d97c40d6af fix: reject plain HTTP repository sources 2026-09-13 01:12:01 +09:00
Hare 2d512b6be6 fix: remove stale HTTP repository access branch 2026-09-13 01:04:46 +09:00
Hare f061a95b48 fix: validate repository URI before source kind 2026-09-13 00:45:43 +09:00
Hare eefdef1bef fix: lock verified runtime public keys 2026-09-13 00:32:44 +09:00
Hare 7675f81999 fix: reject plain HTTP repository sources 2026-09-13 00:29:16 +09:00
Hare 8fb592071f fix: fence stale repository SSH probes 2026-09-13 00:27:32 +09:00
Hare 2528312142 fix: lock verified Runtime public keys in settings 2026-09-13 00:23:25 +09:00
Hare 08d7965ea8 fix: centralize repository access expiry 2026-09-13 00:20:25 +09:00
Hare e0badad91f fix: invalidate stale repository SSH probes 2026-09-13 00:17:12 +09:00
Hare f0a91ce2d8 fix: centralize repository access expiry 2026-09-13 00:11:03 +09:00
Hare 24cab83f48 test: cover repository credential rotate visibility 2026-09-13 00:05:14 +09:00
Hare 33a2b5d702 fix: unify workspace request routing 2026-09-12 23:49:13 +09:00
Hare 7f807004ad Update AGENTS.md 2026-09-12 22:47:35 +09:00
Hare 5564425488 fix: retry retained workdir removal 2026-09-12 13:30:25 +09:00
Hare 4a89c04732 fix: allow corrupted workdir removal 2026-09-12 01:43:37 +09:00
Hare ec5a403ec6 feat: replace linked worktrees with runtime clones 2026-09-12 01:43:21 +09:00
Hare f6ce1df766 feat: support workspace-managed SSH repository access 2026-09-11 22:55:06 +09:00
Hare 9d7ddcc04a fix: enforce Workspace Runtime binding lifecycle 2026-09-09 13:20:00 +09:00
Hare 3df611636b feat: complete Workspace Runtime management flow 2026-09-09 09:38:26 +09:00
Hare d0999326bd fix: use Backend audience for embedded Runtime requests 2026-09-09 09:38:26 +09:00
Hare 6fbc65476c fix: migrate standalone Worker manifest snapshots 2026-09-09 01:22:59 +09:00
Hare fcc7d79d80 feat: authorize scoped symlink paths lexically 2026-09-09 00:51:21 +09:00
Hare 18fd6a1f5e fix: restore remote Runtime management contracts 2026-09-09 00:26:04 +09:00
Hare a072562034 chore: merge develop into hare/develop 2026-09-08 13:04:10 +09:00
Hare 2b4a2bc688 fix: fail closed on missing workspace capability 2026-09-08 12:40:04 +09:00
Hare 3344d9f8b2 refactor: remove server-global runtime trust 2026-09-08 12:19:39 +09:00
Hare fae36d220d fix: complete Runtime verification cutover 2026-09-08 10:11:18 +09:00
Hare 7b6a84a550 feat: project Runtime verification state 2026-09-08 09:22:44 +09:00
Hare f29c343879 feat: verify Workspace-signed Runtime bindings 2026-09-08 08:13:58 +09:00
Hare f5e9f49a13 fix: bind Runtime WebSockets to egress policy 2026-09-08 05:29:53 +09:00
Hare 73a35599d2 fix: complete configured Runtime onboarding 2026-09-08 05:15:14 +09:00
Hare 5080d7860e fix: preserve Runtime binding trust boundaries 2026-09-08 04:54:23 +09:00
Hare 7fb1d4056c feat: add manual Runtime trust setup UI 2026-09-08 04:22:45 +09:00
Hare 243a081874 feat: add configured Workspace Runtime bindings 2026-09-08 04:22:32 +09:00
Hare 04924cf796 fix: bound Runtime issuer trust surfaces 2026-09-08 02:31:58 +09:00
Hare 8f0917b8bc docs: remove obsolete local profile override guidance 2026-09-08 02:13:26 +09:00
Hare d4ad46127a feat: add GPT-6 Astra model catalog entry 2026-09-08 02:13:26 +09:00
Hare fba5ecf54c fix: harden Workspace issuer bootstrap 2026-09-08 02:00:19 +09:00
Hare e035df9e7b feat: add Runtime Workspace issuer trust 2026-09-08 01:54:10 +09:00
Hare 3baf0b6358 feat: integrate Workspace signing identity authority 2026-09-08 00:48:45 +09:00
Hare 4de04e42b5 fix: harden identity publication recovery 2026-09-08 00:30:38 +09:00
Hare ebec98a14c feat: add Workspace signing identity authority 2026-09-07 23:52:20 +09:00
150 changed files with 24101 additions and 20455 deletions
+7 -9
View File
@@ -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
+33 -654
View File
@@ -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"
@@ -4536,9 +4240,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 +4472,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 +4781,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 +5094,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 +5397,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 +5408,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 +5437,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 +5534,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 +5661,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 +5924,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"heck", "heck",
"wit-parser 0.244.0", "wit-parser",
] ]
[[package]] [[package]]
@@ -6571,10 +5971,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 +5992,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 +6055,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]]
@@ -6798,6 +6176,7 @@ dependencies = [
"tokio", "tokio",
"toml", "toml",
"tui", "tui",
"uuid",
"worker", "worker",
"workspace-api", "workspace-api",
] ]
+2 -1
View File
@@ -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",
+47 -20
View File
@@ -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()),
}, },
}; };
+8 -5
View File
@@ -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::{
+4 -16
View File
@@ -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,
+8 -1
View File
@@ -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]
+12 -14
View File
@@ -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);
+40 -9
View File
@@ -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,
+72 -34
View File
@@ -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> {
+23 -24
View File
@@ -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(
+23 -26
View File
@@ -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
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+47 -11
View File
@@ -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
View File
@@ -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();
+1
View File
@@ -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(),
} }
} }
+38
View File
@@ -1599,12 +1599,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
@@ -1623,6 +1641,25 @@ pub enum Permission {
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn scope_rule_defaults_to_resolved_symlink_policy() {
let rule: ScopeRule = serde_json::from_value(serde_json::json!({
"target": "/workspace",
"permission": "read"
}))
.unwrap();
assert!(rule.recursive);
assert_eq!(rule.symlink_policy, SymlinkPolicy::Resolved);
let logical: ScopeRule = serde_json::from_value(serde_json::json!({
"target": "/workspace",
"permission": "read",
"symlink_policy": "logical"
}))
.unwrap();
assert_eq!(logical.symlink_policy, SymlinkPolicy::Logical);
}
#[test] #[test]
fn worker_state_snapshot_apply_is_monotonic_and_detects_conflicts() { fn worker_state_snapshot_apply_is_monotonic_and_detects_conflicts() {
let mut current = WorkerStateSnapshot::initial(4); let mut current = WorkerStateSnapshot::initial(4);
@@ -2462,6 +2499,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();
+3 -2
View File
@@ -11,8 +11,8 @@ use crate::{
PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission,
RewindSummary, RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, SessionContentPart, RewindSummary, RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, SessionContentPart,
SessionEntryProvenance, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry, SessionEntryProvenance, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry,
SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, ToolResultDisposition, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, SymlinkPolicy,
TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, WorkerBusyState,
WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope,
WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState,
WorkerStateSnapshot, WorkerStatus, WorkerStateSnapshot, WorkerStatus,
@@ -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);
@@ -63,6 +63,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
@@ -682,6 +684,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 +856,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(
+111 -6
View File
@@ -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() {
+2
View File
@@ -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(),
+1
View File
@@ -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 =
+1
View File
@@ -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()
} }
+103 -10
View File
@@ -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,
}; };
@@ -463,6 +495,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 +568,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"
})) }))
@@ -518,7 +611,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(),
+5 -5
View File
@@ -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),
}, },
})) }))
+1
View File
@@ -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
+58 -8
View File
@@ -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),
@@ -293,7 +298,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 +315,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 +369,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 +420,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 +452,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 +592,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)?;
+31 -2
View File
@@ -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>;
+345 -28
View File
@@ -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();
@@ -1533,6 +1677,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 +1875,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 +1885,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 +1978,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 +1988,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 +2018,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 +2088,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 +2125,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 +2162,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 +2178,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 +2342,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 +2425,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
View File
@@ -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(&current).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]
+1 -1
View File
@@ -39,7 +39,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
+87 -220
View File
@@ -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 { .. })
));
}
} }
+8 -5
View File
@@ -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>,
} }
+3
View File
@@ -287,6 +287,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 +297,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,
} }
} }
+280 -13
View File
@@ -15,7 +15,10 @@ use std::io::{BufReader, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
const SCHEMA_VERSION: u32 = 4; const SCHEMA_VERSION: u32 = 6;
const PREVIOUS_SCHEMA_VERSION: u32 = 5;
const EXECUTION_SCHEMA_VERSION: u32 = 4;
const PRE_EXECUTION_SCHEMA_VERSION: u32 = 3;
const RUNTIME_FILE: &str = "runtime.json"; const RUNTIME_FILE: &str = "runtime.json";
const WORKERS_DIR: &str = "workers"; const WORKERS_DIR: &str = "workers";
const WORKER_FILE: &str = "worker.json"; const WORKER_FILE: &str = "worker.json";
@@ -283,6 +286,7 @@ pub(crate) struct PersistedWorkerExecutionBinding {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct PersistedWorkerExecution { pub(crate) struct PersistedWorkerExecution {
pub(crate) last_run_generation: u64,
pub(crate) binding: Option<PersistedWorkerExecutionBinding>, pub(crate) binding: Option<PersistedWorkerExecutionBinding>,
pub(crate) restore_intent: WorkerRestoreIntent, pub(crate) restore_intent: WorkerRestoreIntent,
} }
@@ -370,8 +374,8 @@ fn plan_runtime_store_migration(
format!("Runtime store schema version {schema_version} is out of range"), format!("Runtime store schema version {schema_version} is out of range"),
) )
})?; })?;
let staging = migration_sibling(root, "schema-v4-staging")?; let staging = migration_sibling(root, "schema-v6-staging")?;
let backup = migration_sibling(root, "pre-schema-v4-backup")?; let backup = migration_sibling(root, "pre-schema-v6-backup")?;
if staging.exists() || backup.exists() { if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt( return Err(runtime_store_corrupt(
root, root,
@@ -397,11 +401,14 @@ fn plan_runtime_store_migration(
}; };
return Ok((plan, Vec::new())); return Ok((plan, Vec::new()));
} }
if current_schema_version != 3 { if !matches!(
current_schema_version,
PRE_EXECUTION_SCHEMA_VERSION | EXECUTION_SCHEMA_VERSION | PREVIOUS_SCHEMA_VERSION
) {
return Err(runtime_store_corrupt( return Err(runtime_store_corrupt(
&runtime_path, &runtime_path,
format!( format!(
"unsupported Runtime store schema version {schema_version}; expected 3 or {SCHEMA_VERSION}" "unsupported Runtime store schema version {schema_version}; expected {PRE_EXECUTION_SCHEMA_VERSION}, {EXECUTION_SCHEMA_VERSION}, {PREVIOUS_SCHEMA_VERSION}, or {SCHEMA_VERSION}"
), ),
)); ));
} }
@@ -428,6 +435,16 @@ fn plan_runtime_store_migration(
runtime_store_corrupt(&source_dir, "Worker directory is not UTF-8".to_string()) runtime_store_corrupt(&source_dir, "Worker directory is not UTF-8".to_string())
})?; })?;
let snapshot_path = source_dir.join(WORKER_FILE); let snapshot_path = source_dir.join(WORKER_FILE);
if !snapshot_path
.try_exists()
.map_err(|source| RuntimeError::StoreIo {
operation: "inspect Worker snapshot",
path: snapshot_path.clone(),
source,
})?
{
continue;
}
let snapshot: serde_json::Value = read_json(&snapshot_path, "read Worker snapshot")?; let snapshot: serde_json::Value = read_json(&snapshot_path, "read Worker snapshot")?;
let (worker_id, workspace_id, legacy_mapping) = if current_schema_version == 1 { let (worker_id, workspace_id, legacy_mapping) = if current_schema_version == 1 {
let legacy_worker_id = name.parse::<u64>().map_err(|_| { let legacy_worker_id = name.parse::<u64>().map_err(|_| {
@@ -616,6 +633,38 @@ fn migrate_v1_worker_document(
Ok(snapshot) Ok(snapshot)
} }
fn max_persisted_run_generation(snapshot_path: &Path) -> Result<u64, RuntimeError> {
let worker_dir = snapshot_path.parent().ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"Worker snapshot path is missing its aggregate directory".to_string(),
)
})?;
let runs_dir = worker_dir.join("runs");
if !runs_dir
.try_exists()
.map_err(|source| runtime_io_error("inspect Worker runs", &runs_dir, source))?
{
return Ok(0);
}
let entries = fs::read_dir(&runs_dir)
.map_err(|source| runtime_io_error("read Worker runs", &runs_dir, source))?;
let mut max_generation = 0;
for entry in entries {
let entry =
entry.map_err(|source| runtime_io_error("read Worker runs", &runs_dir, source))?;
let Some(generation) = entry
.file_name()
.to_str()
.and_then(|name| name.parse::<u64>().ok())
else {
continue;
};
max_generation = max_generation.max(generation);
}
Ok(max_generation)
}
fn migrate_worker_document( fn migrate_worker_document(
mut document: serde_json::Value, mut document: serde_json::Value,
source_schema_version: u32, source_schema_version: u32,
@@ -640,7 +689,7 @@ fn migrate_worker_document(
"Worker snapshot must be an object".to_string(), "Worker snapshot must be an object".to_string(),
) )
})?; })?;
let run_generation = object let declared_run_generation = object
.remove("run_generation") .remove("run_generation")
.map(|value| { .map(|value| {
value.as_u64().ok_or_else(|| { value.as_u64().ok_or_else(|| {
@@ -650,9 +699,45 @@ fn migrate_worker_document(
) )
}) })
}) })
.transpose()? .transpose()?;
.filter(|generation| *generation > 0);
let legacy_execution = object.remove("execution"); let legacy_execution = object.remove("execution");
let execution = legacy_execution
.as_ref()
.and_then(serde_json::Value::as_object);
let persisted_last_run_generation = execution
.and_then(|execution| execution.get("last_run_generation"))
.map(|value| {
value.as_u64().ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"Worker execution last_run_generation must be an unsigned integer".to_string(),
)
})
})
.transpose()?;
let binding_run_generation = execution
.and_then(|execution| execution.get("binding"))
.and_then(serde_json::Value::as_object)
.and_then(|binding| binding.get("run_generation"))
.map(|value| {
value.as_u64().ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"Worker execution binding run_generation must be an unsigned integer"
.to_string(),
)
})
})
.transpose()?;
let run_generation = declared_run_generation
.into_iter()
.chain(persisted_last_run_generation)
.chain(binding_run_generation)
.chain(std::iter::once(max_persisted_run_generation(
snapshot_path,
)?))
.max()
.unwrap_or(0);
if !object.contains_key("working_directory") { if !object.contains_key("working_directory") {
if let Some(working_directory) = legacy_execution if let Some(working_directory) = legacy_execution
.as_ref() .as_ref()
@@ -663,6 +748,42 @@ fn migrate_worker_document(
object.insert("working_directory".to_string(), working_directory); object.insert("working_directory".to_string(), working_directory);
} }
} }
let legacy_materialization = object
.get("working_directory")
.and_then(|working_directory| working_directory.get("summary"))
.and_then(|summary| summary.get("materializer_kind"))
.and_then(serde_json::Value::as_str)
.is_some_and(|kind| matches!(kind, "runtime_git_cache" | "local_git_worktree"));
if legacy_materialization {
object.insert("working_directory".to_string(), serde_json::Value::Null);
}
if let Some(profile_source) = object
.get_mut("request")
.and_then(serde_json::Value::as_object_mut)
.and_then(|request| request.get_mut("profile_source"))
.and_then(serde_json::Value::as_object_mut)
&& profile_source
.get("kind")
.and_then(serde_json::Value::as_str)
== Some("http")
{
let archive = profile_source
.get_mut("location")
.and_then(serde_json::Value::as_object_mut)
.and_then(|location| location.remove("archive"))
.ok_or_else(|| {
runtime_store_corrupt(
snapshot_path,
"legacy HTTP profile source is missing its archive".to_string(),
)
})?;
profile_source.clear();
profile_source.insert(
"kind".to_string(),
serde_json::Value::String("workspace_config".to_string()),
);
profile_source.insert("archive".to_string(), archive);
}
object.insert( object.insert(
"schema_version".to_string(), "schema_version".to_string(),
serde_json::Value::from(SCHEMA_VERSION), serde_json::Value::from(SCHEMA_VERSION),
@@ -674,9 +795,8 @@ fn migrate_worker_document(
object.insert( object.insert(
"execution".to_string(), "execution".to_string(),
serde_json::json!({ serde_json::json!({
"binding": run_generation.map(|run_generation| { "last_run_generation": run_generation,
serde_json::json!({ "run_generation": run_generation }) "binding": null,
}),
"restore_intent": "explicit", "restore_intent": "explicit",
}), }),
); );
@@ -1048,8 +1168,8 @@ fn migrate_runtime_store(
if !plan.migration_required { if !plan.migration_required {
return Ok(plan); return Ok(plan);
} }
let staging = migration_sibling(root, "schema-v4-staging")?; let staging = migration_sibling(root, "schema-v6-staging")?;
let backup = migration_sibling(root, "pre-schema-v4-backup")?; let backup = migration_sibling(root, "pre-schema-v6-backup")?;
if staging.exists() || backup.exists() { if staging.exists() || backup.exists() {
return Err(runtime_store_corrupt( return Err(runtime_store_corrupt(
root, root,
@@ -1322,6 +1442,18 @@ impl WorkerSnapshot {
), ),
}); });
} }
if let Some(binding) = self.execution.binding.as_ref()
&& binding.run_generation != self.execution.last_run_generation
{
return Err(RuntimeError::StoreCorrupt {
operation: "read worker snapshot",
path: path.to_path_buf(),
message: format!(
"execution binding run_generation {} does not match last_run_generation {}",
binding.run_generation, self.execution.last_run_generation
),
});
}
match (self.status, self.execution.restore_intent) { match (self.status, self.execution.restore_intent) {
(status, WorkerRestoreIntent::Automatic) if status.is_active() => { (status, WorkerRestoreIntent::Automatic) if status.is_active() => {
let Some(binding) = self.execution.binding.as_ref() else { let Some(binding) = self.execution.binding.as_ref() else {
@@ -1491,3 +1623,138 @@ fn sync_directory(path: &Path, operation: &'static str) -> Result<(), RuntimeErr
source, source,
}) })
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_v4_migration_plan_ignores_orphan_worker_directories() {
let root = tempfile::tempdir().unwrap();
fs::write(
root.path().join(RUNTIME_FILE),
serde_json::to_vec_pretty(&serde_json::json!({
"schema_version": PREVIOUS_SCHEMA_VERSION,
"display_name": null,
"backend": "fs_store",
"status": "running",
"next_diagnostic_id": 1,
"config_bundles": {},
"workspace_owners": {},
"diagnostics": []
}))
.unwrap(),
)
.unwrap();
fs::create_dir_all(root.path().join(WORKERS_DIR).join("orphan").join("session")).unwrap();
fs::write(
root.path()
.join(WORKERS_DIR)
.join("orphan")
.join("session")
.join("history.json"),
b"[]",
)
.unwrap();
let (plan, _) = plan_runtime_store_migration(root.path(), "runtime-test").unwrap();
assert!(plan.migration_required);
assert_eq!(plan.current_schema_version, PREVIOUS_SCHEMA_VERSION);
assert_eq!(plan.target_schema_version, SCHEMA_VERSION);
assert_eq!(plan.worker_count, 0);
}
#[test]
fn schema_v5_worker_migration_recovers_last_generation_from_run_aggregates() {
let root = tempfile::tempdir().unwrap();
let worker_dir = root.path().join("worker-a");
fs::create_dir_all(worker_dir.join("runs/1")).unwrap();
fs::create_dir_all(worker_dir.join("runs/7")).unwrap();
fs::create_dir_all(worker_dir.join("runs/incomplete")).unwrap();
let path = worker_dir.join(WORKER_FILE);
let source = serde_json::json!({
"schema_version": 5,
"execution": {
"binding": null,
"restore_intent": "explicit"
}
});
let migrated =
migrate_worker_document(source, PREVIOUS_SCHEMA_VERSION, None, &path).unwrap();
assert_eq!(
migrated["execution"]["last_run_generation"],
serde_json::json!(7)
);
assert_eq!(migrated["execution"]["binding"], serde_json::Value::Null);
}
#[test]
fn schema_v4_worker_migration_discards_unsupported_linked_worktree_binding() {
let source = serde_json::json!({
"schema_version": 4,
"request": {
"profile_source": {
"kind": "http",
"location": {
"url": "https://workspace.example.test/archive",
"etag": "profile-source:test",
"archive": {
"id": "profiles-v1",
"digest": "sha256:test",
"size_bytes": 1,
"source_graph": {
"source_count": 1,
"total_source_bytes": 1,
"entrypoints": {},
"import_count": 0
}
}
}
}
},
"working_directory": {
"summary": {
"materializer_kind": "runtime_git_cache"
}
}
});
let path = Path::new("worker.json");
let migrated =
migrate_worker_document(source, EXECUTION_SCHEMA_VERSION, None, path).unwrap();
assert_eq!(migrated["schema_version"], SCHEMA_VERSION);
assert_eq!(migrated["status"], "stopped");
assert_eq!(migrated["working_directory"], serde_json::Value::Null);
assert_eq!(
migrated["request"]["profile_source"]["kind"],
"workspace_config"
);
assert_eq!(
migrated["request"]["profile_source"]["archive"]["id"],
"profiles-v1"
);
assert_eq!(migrated["execution"]["restore_intent"], "explicit");
}
#[test]
fn schema_v4_worker_migration_preserves_runtime_clone_observation() {
let source = serde_json::json!({
"schema_version": 4,
"working_directory": {
"summary": {
"materializer_kind": "runtime_git_clone"
}
}
});
let expected = source["working_directory"].clone();
let path = Path::new("worker.json");
let migrated =
migrate_worker_document(source, EXECUTION_SCHEMA_VERSION, None, path).unwrap();
assert_eq!(migrated["working_directory"], expected);
}
}
File diff suppressed because it is too large Load Diff
+4
View File
@@ -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};
File diff suppressed because it is too large Load Diff
+159 -62
View File
@@ -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";
+290 -71
View File
@@ -189,6 +189,23 @@ impl Runtime {
Ok(()) Ok(())
} }
pub fn install_workspace_backend_resource_client(
&self,
workspace_id: impl Into<String>,
client: Arc<dyn BackendResourceClient>,
) -> Result<(), RuntimeError> {
let workspace_id = workspace_id.into();
if workspace_id.trim().is_empty() {
return Err(RuntimeError::InvalidRequest(
"Backend resource client Workspace id is empty".to_string(),
));
}
self.lock()?
.workspace_backend_resource_clients
.insert(workspace_id, BackendResourceClientRef(client));
Ok(())
}
/// Create or restore a filesystem-backed Runtime. /// Create or restore a filesystem-backed Runtime.
/// ///
/// The store is scoped by `options.root`; if the directory already exists, /// The store is scoped by `options.root`; if the directory already exists,
@@ -439,26 +456,51 @@ impl Runtime {
&self, &self,
ssh: &mut crate::catalog::RepositorySshMaterializationAccess, ssh: &mut crate::catalog::RepositorySshMaterializationAccess,
) -> Result<(), RuntimeError> { ) -> Result<(), RuntimeError> {
if !ssh.private_key.expose().is_empty() && !ssh.known_hosts_entry.expose().is_empty() { if ssh.credential_candidates.is_empty() {
return Err(RuntimeError::InvalidRequest(
"Repository SSH access requires at least one credential candidate".to_string(),
));
}
if ssh
.credential_candidates
.iter()
.all(|candidate| !candidate.private_key.expose().is_empty())
&& !ssh.known_hosts_entry.expose().is_empty()
{
return Ok(()); return Ok(());
} }
let (client, runtime_id) = { let (client, runtime_id) = {
let state = self.lock()?; let state = self.lock()?;
let client = state.backend_resource_client.clone().ok_or_else(|| { let client = state
RuntimeError::InvalidRequest( .workspace_backend_resource_clients
"Backend Repository access resource client is unavailable".to_string(), .get(&ssh.secret_resource.workspace_id)
) .cloned()
})?; .or_else(|| state.backend_resource_client.clone())
.ok_or_else(|| {
RuntimeError::InvalidRequest(format!(
"Backend Repository access resource client is unavailable for Workspace `{}`",
ssh.secret_resource.workspace_id
))
})?;
let runtime_id = state.runtime_identity.clone().ok_or_else(|| { let runtime_id = state.runtime_identity.clone().ok_or_else(|| {
RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string()) RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string())
})?; })?;
(client, runtime_id) (client, runtime_id)
}; };
tracing::info!(
target: "yoi::repository_access",
event = "repository_access_resource_fetch_started",
workspace_id = %ssh.secret_resource.workspace_id,
resource_id = %ssh.secret_resource.resource_id,
runtime_id = %runtime_id,
credential_candidate_count = ssh.credential_candidates.len(),
"fetching Repository SSH access resource from Workspace Backend"
);
let mut response = client let mut response = client
.0 .0
.fetch_resource(BackendResourceFetchRequest { .fetch_resource(BackendResourceFetchRequest {
handle: ssh.secret_resource.clone(), handle: ssh.secret_resource.clone(),
runtime_id, runtime_id: runtime_id.clone(),
worker_id: None, worker_id: None,
audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(), audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(),
}) })
@@ -481,10 +523,40 @@ impl Runtime {
"Backend Repository SSH access resource payload was invalid".to_string(), "Backend Repository SSH access resource payload was invalid".to_string(),
) )
})?; })?;
ssh.private_key = if secret.credential_candidates.len() != ssh.credential_candidates.len()
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key)); || secret
.credential_candidates
.iter()
.zip(&ssh.credential_candidates)
.any(|(secret, metadata)| {
secret.credential_id != metadata.credential_id
|| secret.credential_revision != metadata.credential_revision
})
{
return Err(RuntimeError::InvalidRequest(
"Backend Repository SSH access resource credential metadata was invalid"
.to_string(),
));
}
for (candidate, secret) in ssh
.credential_candidates
.iter_mut()
.zip(&mut secret.credential_candidates)
{
candidate.private_key =
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key));
}
ssh.known_hosts_entry = ssh.known_hosts_entry =
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.known_hosts_entry)); crate::catalog::SensitiveString::new(std::mem::take(&mut secret.known_hosts_entry));
tracing::info!(
target: "yoi::repository_access",
event = "repository_access_resource_fetch_succeeded",
workspace_id = %ssh.secret_resource.workspace_id,
resource_id = %ssh.secret_resource.resource_id,
runtime_id = %runtime_id,
credential_candidate_count = ssh.credential_candidates.len(),
"fetched Repository SSH access resource from Workspace Backend"
);
Ok(()) Ok(())
} }
@@ -492,10 +564,22 @@ impl Runtime {
&self, &self,
mut request: WorkingDirectoryRepositoryAccessRequest, mut request: WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), RuntimeError> { ) -> Result<(), RuntimeError> {
let materialization_runtime_id = request.materialization.runtime_id.clone();
let ssh = request.materialization.ssh.as_mut().ok_or_else(|| { let ssh = request.materialization.ssh.as_mut().ok_or_else(|| {
RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string()) RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string())
})?; })?;
self.resolve_repository_access_resource(ssh).await?; if let Err(error) = self.resolve_repository_access_resource(ssh).await {
tracing::warn!(
target: "yoi::repository_access",
event = "repository_access_resource_fetch_failed",
workspace_id = %ssh.secret_resource.workspace_id,
resource_id = %ssh.secret_resource.resource_id,
runtime_id = %materialization_runtime_id,
error = %error,
"failed to fetch Repository SSH access resource from Workspace Backend"
);
return Err(error);
}
self.authorize_working_directory_repository_access(request) self.authorize_working_directory_repository_access(request)
} }
@@ -890,11 +974,12 @@ impl Runtime {
}; };
let spawn_result = backend.spawn_worker(spawn_request); let spawn_result = backend.spawn_worker(spawn_request);
let (handle, working_directory) = match spawn_result { let (handle, initial_worker_state, working_directory) = match spawn_result {
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle, handle,
worker_state,
working_directory, working_directory,
} => (handle, working_directory), } => (handle, worker_state, working_directory),
WorkerExecutionSpawnResult::Rejected(result) WorkerExecutionSpawnResult::Rejected(result)
| WorkerExecutionSpawnResult::Errored(result) => { | WorkerExecutionSpawnResult::Errored(result) => {
self.rollback_failed_create(&worker_ref)?; self.rollback_failed_create(&worker_ref)?;
@@ -950,6 +1035,7 @@ impl Runtime {
let detail = match self.commit_created_worker( let detail = match self.commit_created_worker(
&worker_ref, &worker_ref,
handle.clone(), handle.clone(),
initial_worker_state.clone(),
working_directory, working_directory,
dispatch_result, dispatch_result,
) { ) {
@@ -968,6 +1054,7 @@ impl Runtime {
match self.commit_created_worker( match self.commit_created_worker(
&worker_ref, &worker_ref,
handle.clone(), handle.clone(),
initial_worker_state,
working_directory, working_directory,
WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn), WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn),
) { ) {
@@ -1260,11 +1347,13 @@ impl Runtime {
match backend.restore_worker(request) { match backend.restore_worker(request) {
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle, handle,
worker_state,
working_directory, working_directory,
} => { } => {
self.commit_restored_worker_execution( self.commit_restored_worker_execution(
worker_ref, worker_ref,
handle, handle,
worker_state,
WorkerStatus::Idle, WorkerStatus::Idle,
working_directory, working_directory,
)?; )?;
@@ -1656,6 +1745,7 @@ impl Runtime {
&self, &self,
worker_ref: &WorkerRef, worker_ref: &WorkerRef,
handle: WorkerExecutionHandle, handle: WorkerExecutionHandle,
initial_worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<CatalogWorkingDirectoryStatus>, working_directory: Option<CatalogWorkingDirectoryStatus>,
result: WorkerExecutionResult, result: WorkerExecutionResult,
) -> Result<WorkerDetail, RuntimeError> { ) -> Result<WorkerDetail, RuntimeError> {
@@ -1665,7 +1755,7 @@ impl Runtime {
worker.execution_handle = Some(handle); worker.execution_handle = Some(handle);
worker.execution_bound = true; worker.execution_bound = true;
worker.status = WorkerStatus::Idle; worker.status = WorkerStatus::Idle;
worker.worker_state = None; let _ = worker.apply_worker_state(&initial_worker_state);
if let Some(snapshot) = result.worker_state.as_ref() { if let Some(snapshot) = result.worker_state.as_ref() {
let _ = worker.apply_worker_state(snapshot); let _ = worker.apply_worker_state(snapshot);
} }
@@ -2192,10 +2282,12 @@ impl Runtime {
match backend.restore_worker(request) { match backend.restore_worker(request) {
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle, handle,
worker_state,
working_directory, working_directory,
} => self.commit_restored_worker_execution( } => self.commit_restored_worker_execution(
&candidate.worker_ref, &candidate.worker_ref,
handle, handle,
worker_state,
WorkerStatus::Idle, WorkerStatus::Idle,
working_directory, working_directory,
)?, )?,
@@ -2213,6 +2305,7 @@ impl Runtime {
&self, &self,
worker_ref: &WorkerRef, worker_ref: &WorkerRef,
handle: WorkerExecutionHandle, handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
status: WorkerStatus, status: WorkerStatus,
working_directory: Option<CatalogWorkingDirectoryStatus>, working_directory: Option<CatalogWorkingDirectoryStatus>,
) -> Result<(), RuntimeError> { ) -> Result<(), RuntimeError> {
@@ -2223,6 +2316,7 @@ impl Runtime {
worker.execution_handle = Some(handle); worker.execution_handle = Some(handle);
worker.execution_bound = true; worker.execution_bound = true;
worker.status = status; worker.status = status;
let _ = worker.apply_worker_state(&worker_state);
worker.restore_intent = restore_intent_for_status(worker.status); worker.restore_intent = restore_intent_for_status(worker.status);
worker.working_directory = working_directory; worker.working_directory = working_directory;
} }
@@ -2418,6 +2512,7 @@ struct RuntimeState {
status: RuntimeStatus, status: RuntimeStatus,
execution_backend: Option<WorkerExecutionBackendRef>, execution_backend: Option<WorkerExecutionBackendRef>,
backend_resource_client: Option<BackendResourceClientRef>, backend_resource_client: Option<BackendResourceClientRef>,
workspace_backend_resource_clients: BTreeMap<String, BackendResourceClientRef>,
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: u64, next_diagnostic_id: u64,
workers: BTreeMap<WorkerId, WorkerRecord>, workers: BTreeMap<WorkerId, WorkerRecord>,
@@ -2448,6 +2543,7 @@ impl RuntimeState {
status: RuntimeStatus::Running, status: RuntimeStatus::Running,
execution_backend: None, execution_backend: None,
backend_resource_client: None, backend_resource_client: None,
workspace_backend_resource_clients: BTreeMap::new(),
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
@@ -2479,6 +2575,7 @@ impl RuntimeState {
status: RuntimeStatus::Running, status: RuntimeStatus::Running,
execution_backend: None, execution_backend: None,
backend_resource_client: None, backend_resource_client: None,
workspace_backend_resource_clients: BTreeMap::new(),
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
@@ -2509,12 +2606,7 @@ impl RuntimeState {
let diagnostics = persisted.diagnostics; let diagnostics = persisted.diagnostics;
let next_diagnostic_id = persisted.next_diagnostic_id; let next_diagnostic_id = persisted.next_diagnostic_id;
for (worker_id, worker) in persisted.workers { for (worker_id, worker) in persisted.workers {
let run_generation = worker let run_generation = worker.execution.last_run_generation;
.execution
.binding
.as_ref()
.map(|binding| binding.run_generation)
.unwrap_or(0);
workers.insert( workers.insert(
worker_id, worker_id,
WorkerRecord { WorkerRecord {
@@ -2542,6 +2634,7 @@ impl RuntimeState {
status: persisted.status, status: persisted.status,
execution_backend: None, execution_backend: None,
backend_resource_client: None, backend_resource_client: None,
workspace_backend_resource_clients: BTreeMap::new(),
next_diagnostic_id, next_diagnostic_id,
workers, workers,
config_bundles: BTreeMap::new(), config_bundles: BTreeMap::new(),
@@ -3299,6 +3392,7 @@ impl WorkerRecord {
request: self.request.clone(), request: self.request.clone(),
status: self.status, status: self.status,
execution: PersistedWorkerExecution { execution: PersistedWorkerExecution {
last_run_generation: self.run_generation,
binding: self binding: self
.execution_bound .execution_bound
.then_some(PersistedWorkerExecutionBinding { .then_some(PersistedWorkerExecutionBinding {
@@ -3334,6 +3428,10 @@ fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
"repository_access_credential_unavailable", "repository_access_credential_unavailable",
"Repository access credential lease is unavailable or already consumed", "Repository access credential lease is unavailable or already consumed",
), ),
BackendResourceError::Timeout => (
"repository_access_resource_fetch_timeout",
"Timed out while fetching Repository SSH access from Workspace Backend",
),
BackendResourceError::Transport { .. } => ( BackendResourceError::Transport { .. } => (
"repository_access_provider_unavailable", "repository_access_provider_unavailable",
"Repository access credential provider is unavailable", "Repository access credential provider is unavailable",
@@ -3605,8 +3703,9 @@ mod tests {
use super::*; use super::*;
use crate::catalog::{ use crate::catalog::{
ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext, ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext,
RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryClaim, RepositorySshCredentialCandidate, RepositorySshMaterializationAccess, SensitiveString,
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef, WorkingDirectoryClaim, WorkingDirectoryRepository, WorkingDirectoryRequest,
WorkspaceApiRef,
}; };
use crate::config_bundle::{ use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration, ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
@@ -3660,6 +3759,10 @@ mod tests {
BackendResourceError::MissingResource, BackendResourceError::MissingResource,
"repository_access_credential_unavailable", "repository_access_credential_unavailable",
), ),
(
BackendResourceError::Timeout,
"repository_access_resource_fetch_timeout",
),
( (
BackendResourceError::Unauthorized { BackendResourceError::Unauthorized {
message: "denied".to_string(), message: "denied".to_string(),
@@ -3913,7 +4016,7 @@ mod tests {
source_fingerprint: "sha256:source".to_string(), source_fingerprint: "sha256:source".to_string(),
selector: None, selector: None,
}, },
materializer: MaterializerKind::RuntimeGitCache, materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: Some("working-directory-1".to_string()), backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext { materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
@@ -3921,10 +4024,12 @@ mod tests {
operation_id: "operation-1".to_string(), operation_id: "operation-1".to_string(),
config_revision: 1, config_revision: 1,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess { ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(), credential_candidates: vec![RepositorySshCredentialCandidate {
credential_revision: 1, credential_id: "credential-1".to_string(),
credential_revision: 1,
private_key: SensitiveString::new("private-key-bytes"),
}],
host_trust_id: "host-trust-1".to_string(), host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1, host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly, access: workspace_api::RepositoryAccessMode::ReadOnly,
@@ -3933,7 +4038,6 @@ mod tests {
repository_source_fingerprint: "sha256:source".to_string(), repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(), repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: repository_resource_handle(), secret_resource: repository_resource_handle(),
private_key: SensitiveString::new("private-key-bytes"),
known_hosts_entry: SensitiveString::new("known-hosts-entry"), known_hosts_entry: SensitiveString::new("known-hosts-entry"),
}), }),
}), }),
@@ -3993,20 +4097,35 @@ mod tests {
runtime.bind_runtime_identity("runtime-1").unwrap(); runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle(); let handle = repository_resource_handle();
runtime runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient { .install_workspace_backend_resource_client(
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse { "workspace-1",
kind: crate::resource::BackendResourceKind::RepositorySshAccess, Arc::new(TestRepositoryResourceClient {
resource_id: handle.resource_id.clone(), response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
digest: handle.digest.clone(), kind: crate::resource::BackendResourceKind::RepositorySshAccess,
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(), resource_id: handle.resource_id.clone(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret { digest: handle.digest.clone(),
private_key: "private-key-bytes".to_string(), content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE
known_hosts_entry: "known-hosts-entry".to_string(), .to_string(),
}) bytes: serde_json::to_vec(&RepositorySshAccessSecret {
.unwrap(), credential_candidates: vec![
audit_correlation_id: handle.audit_correlation_id.clone(), crate::resource::RepositorySshAccessSecretCandidate {
})), credential_id: "credential-1".to_string(),
})) credential_revision: 1,
private_key: "private-key-bytes-1".to_string(),
},
crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-2".to_string(),
credential_revision: 3,
private_key: "private-key-bytes-2".to_string(),
},
],
known_hosts_entry: "known-hosts-entry".to_string(),
})
.unwrap(),
audit_correlation_id: handle.audit_correlation_id.clone(),
})),
}),
)
.unwrap(); .unwrap();
let request = WorkingDirectoryRepositoryAccessRequest { let request = WorkingDirectoryRepositoryAccessRequest {
working_directory_id: "working-directory-1".to_string(), working_directory_id: "working-directory-1".to_string(),
@@ -4016,10 +4135,19 @@ mod tests {
operation_id: "operation-1".to_string(), operation_id: "operation-1".to_string(),
config_revision: 1, config_revision: 1,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess { ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(), credential_candidates: vec![
credential_revision: 1, RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
private_key: SensitiveString::default(),
},
RepositorySshCredentialCandidate {
credential_id: "credential-2".to_string(),
credential_revision: 3,
private_key: SensitiveString::default(),
},
],
host_trust_id: "host-trust-1".to_string(), host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1, host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly, access: workspace_api::RepositoryAccessMode::ReadOnly,
@@ -4028,7 +4156,6 @@ mod tests {
repository_source_fingerprint: "sha256:source".to_string(), repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(), repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle, secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(), known_hosts_entry: SensitiveString::default(),
}), }),
}, },
@@ -4049,7 +4176,23 @@ mod tests {
let accesses = backend.repository_accesses.lock().unwrap(); let accesses = backend.repository_accesses.lock().unwrap();
assert_eq!(accesses.len(), 1); assert_eq!(accesses.len(), 1);
let access = accesses[0].materialization.ssh.as_ref().unwrap(); let access = accesses[0].materialization.ssh.as_ref().unwrap();
assert_eq!(access.private_key.expose(), "private-key-bytes"); assert_eq!(access.credential_candidates.len(), 2);
assert_eq!(
access.credential_candidates[0].credential_id,
"credential-1"
);
assert_eq!(
access.credential_candidates[1].credential_id,
"credential-2"
);
assert_eq!(
access.credential_candidates[0].private_key.expose(),
"private-key-bytes-1"
);
assert_eq!(
access.credential_candidates[1].private_key.expose(),
"private-key-bytes-2"
);
assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry"); assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry");
} }
@@ -4062,20 +4205,35 @@ mod tests {
runtime.bind_runtime_identity("runtime-1").unwrap(); runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle(); let handle = repository_resource_handle();
runtime runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient { .install_workspace_backend_resource_client(
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse { "workspace-1",
kind: crate::resource::BackendResourceKind::RepositorySshAccess, Arc::new(TestRepositoryResourceClient {
resource_id: handle.resource_id.clone(), response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
digest: handle.digest.clone(), kind: crate::resource::BackendResourceKind::RepositorySshAccess,
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(), resource_id: handle.resource_id.clone(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret { digest: handle.digest.clone(),
private_key: "create-private-key-bytes".to_string(), content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE
known_hosts_entry: "create-known-hosts-entry".to_string(), .to_string(),
}) bytes: serde_json::to_vec(&RepositorySshAccessSecret {
.unwrap(), credential_candidates: vec![
audit_correlation_id: handle.audit_correlation_id.clone(), crate::resource::RepositorySshAccessSecretCandidate {
})), credential_id: "credential-1".to_string(),
})) credential_revision: 1,
private_key: "create-private-key-bytes-1".to_string(),
},
crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-2".to_string(),
credential_revision: 3,
private_key: "create-private-key-bytes-2".to_string(),
},
],
known_hosts_entry: "create-known-hosts-entry".to_string(),
})
.unwrap(),
audit_correlation_id: handle.audit_correlation_id.clone(),
})),
}),
)
.unwrap(); .unwrap();
let request = WorkingDirectoryRequest { let request = WorkingDirectoryRequest {
repository: WorkingDirectoryRepository { repository: WorkingDirectoryRepository {
@@ -4089,7 +4247,7 @@ mod tests {
source_fingerprint: "sha256:source".to_string(), source_fingerprint: "sha256:source".to_string(),
selector: None, selector: None,
}, },
materializer: MaterializerKind::RuntimeGitCache, materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: Some("working-directory-1".to_string()), backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext { materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
@@ -4097,10 +4255,19 @@ mod tests {
operation_id: "operation-create".to_string(), operation_id: "operation-create".to_string(),
config_revision: 1, config_revision: 1,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess { ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(), credential_candidates: vec![
credential_revision: 1, RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
private_key: SensitiveString::default(),
},
RepositorySshCredentialCandidate {
credential_id: "credential-2".to_string(),
credential_revision: 3,
private_key: SensitiveString::default(),
},
],
host_trust_id: "host-trust-1".to_string(), host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1, host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly, access: workspace_api::RepositoryAccessMode::ReadOnly,
@@ -4109,7 +4276,6 @@ mod tests {
repository_source_fingerprint: "sha256:source".to_string(), repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(), repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle, secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(), known_hosts_entry: SensitiveString::default(),
}), }),
}), }),
@@ -4128,7 +4294,14 @@ mod tests {
.as_ref() .as_ref()
.and_then(|materialization| materialization.ssh.as_ref()) .and_then(|materialization| materialization.ssh.as_ref())
.unwrap(); .unwrap();
assert_eq!(access.private_key.expose(), "create-private-key-bytes"); assert_eq!(
access.credential_candidates[0].private_key.expose(),
"create-private-key-bytes-1"
);
assert_eq!(
access.credential_candidates[1].private_key.expose(),
"create-private-key-bytes-2"
);
assert_eq!( assert_eq!(
access.known_hosts_entry.expose(), access.known_hosts_entry.expose(),
"create-known-hosts-entry" "create-known-hosts-entry"
@@ -4325,6 +4498,10 @@ mod tests {
.insert(request.worker_ref.worker_id.clone(), request.context); .insert(request.worker_ref.worker_id.clone(), request.context);
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory: request working_directory: request
.working_directory .working_directory
.as_ref() .as_ref()
@@ -4354,6 +4531,10 @@ mod tests {
.insert(request.worker_ref.worker_id.clone(), request.context); .insert(request.worker_ref.worker_id.clone(), request.context);
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory: request working_directory: request
.working_directory .working_directory
.as_ref() .as_ref()
@@ -5135,7 +5316,27 @@ mod tests {
let detail = runtime.create_worker(request).unwrap(); let detail = runtime.create_worker(request).unwrap();
assert_eq!(detail.status, WorkerStatus::Idle); assert_eq!(detail.status, WorkerStatus::Idle);
assert_eq!(detail.worker_state, None); assert_eq!(
detail.worker_state.as_ref().map(|snapshot| &snapshot.state),
Some(&protocol::WorkerState::Idle)
);
}
#[test]
fn restored_worker_exposes_the_backend_initial_state_snapshot() {
let (runtime, _) = runtime_and_backend();
let created = runtime
.create_worker(task_request("restore initial state"))
.unwrap();
runtime.stop_worker(&created.worker_ref, None).unwrap();
let restored = runtime.restore_worker(&created.worker_ref).unwrap();
let worker_state = restored
.worker_state
.expect("restored Worker must expose its initial state");
assert_eq!(worker_state.execution_generation, 2);
assert_eq!(worker_state.state, protocol::WorkerState::Idle);
} }
#[test] #[test]
@@ -5414,6 +5615,10 @@ mod tests {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory: request working_directory: request
.working_directory .working_directory
.as_ref() .as_ref()
@@ -5513,7 +5718,13 @@ mod tests {
assert_eq!(*backend.run_generations.lock().unwrap(), vec![1, 2]); assert_eq!(*backend.run_generations.lock().unwrap(), vec![1, 2]);
let restored = runtime.worker_detail(&detail.worker_ref).unwrap(); let restored = runtime.worker_detail(&detail.worker_ref).unwrap();
assert_eq!(restored.status, WorkerStatus::Idle); assert_eq!(restored.status, WorkerStatus::Idle);
assert_eq!(restored.worker_state, None); assert_eq!(
restored
.worker_state
.as_ref()
.map(|snapshot| (snapshot.execution_generation, &snapshot.state)),
Some((2, &protocol::WorkerState::Idle))
);
} }
#[test] #[test]
@@ -5864,7 +6075,7 @@ mod tests {
assert!( assert!(
error error
.to_string() .to_string()
.contains("unsupported Runtime store schema version 2; expected 3 or 4") .contains("unsupported Runtime store schema version 2; expected 3, 4, 5, or 6")
); );
let _ = std::fs::remove_dir_all(root); let _ = std::fs::remove_dir_all(root);
@@ -5906,8 +6117,12 @@ mod tests {
let worker_snapshot: serde_json::Value = let worker_snapshot: serde_json::Value =
serde_json::from_slice(&std::fs::read(worker_store_dir.join("worker.json")).unwrap()) serde_json::from_slice(&std::fs::read(worker_store_dir.join("worker.json")).unwrap())
.unwrap(); .unwrap();
assert_eq!(worker_snapshot["schema_version"], serde_json::json!(4)); assert_eq!(worker_snapshot["schema_version"], serde_json::json!(6));
assert_eq!(worker_snapshot["status"], serde_json::json!("stopped")); assert_eq!(worker_snapshot["status"], serde_json::json!("stopped"));
assert_eq!(
worker_snapshot["execution"]["last_run_generation"],
serde_json::json!(1)
);
assert_eq!( assert_eq!(
worker_snapshot["execution"]["binding"]["run_generation"], worker_snapshot["execution"]["binding"]["run_generation"],
serde_json::json!(1) serde_json::json!(1)
@@ -6341,12 +6556,16 @@ mod tests {
); );
let migrated_json: serde_json::Value = let migrated_json: serde_json::Value =
serde_json::from_slice(&std::fs::read(&worker_path).unwrap()).unwrap(); serde_json::from_slice(&std::fs::read(&worker_path).unwrap()).unwrap();
assert_eq!(migrated_json["schema_version"], serde_json::json!(4)); assert_eq!(migrated_json["schema_version"], serde_json::json!(6));
assert_eq!(migrated_json["status"], serde_json::json!("stopped")); assert_eq!(migrated_json["status"], serde_json::json!("stopped"));
assert_eq!( assert_eq!(
migrated_json["execution"]["binding"]["run_generation"], migrated_json["execution"]["last_run_generation"],
serde_json::json!(7) serde_json::json!(7)
); );
assert_eq!(
migrated_json["execution"]["binding"],
serde_json::Value::Null
);
assert_eq!( assert_eq!(
migrated_json["execution"]["restore_intent"], migrated_json["execution"]["restore_intent"],
serde_json::json!("explicit") serde_json::json!("explicit")
@@ -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"));
}
}
+218 -125
View File
@@ -14,10 +14,7 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock, mpsc}; use std::sync::{Arc, Mutex, RwLock, mpsc};
use std::time::Duration; use std::time::Duration;
use crate::auth::{ use crate::auth::{BACKEND_RESOURCE_FETCH_PERMISSION, RuntimeIdentityMaterial};
BACKEND_RESOURCE_FETCH_PERMISSION, RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
RuntimeIdentityMaterial, RuntimeRequestSourceSigner, unix_now_seconds,
};
use crate::catalog::{ use crate::catalog::{
CreateWorkerRequest, ProfileSourceArchiveSource, RepositoryRefObservation, CreateWorkerRequest, ProfileSourceArchiveSource, RepositoryRefObservation,
RepositoryRefObservationRequest, WorkingDirectoryRepositoryAccessRequest, RepositoryRefObservationRequest, WorkingDirectoryRepositoryAccessRequest,
@@ -38,9 +35,8 @@ use crate::worker_source::{
use crate::working_directory::{ use crate::working_directory::{
WorkingDirectoryBinding, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer, WorkingDirectoryBinding, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer,
}; };
use crate::workspace_request::{RuntimeWorkspaceRequest, RuntimeWorkspaceRequestClient};
use async_trait::async_trait; use async_trait::async_trait;
#[cfg(feature = "http-server")]
use futures::StreamExt;
#[cfg(test)] #[cfg(test)]
use protocol::WorkerStatus; use protocol::WorkerStatus;
use protocol::{Event, Method, Segment, WorkerCommandEnvelope}; use protocol::{Event, Method, Segment, WorkerCommandEnvelope};
@@ -323,7 +319,7 @@ pub struct ProfileRuntimeWorkerFactory {
prompt_projection_cache: Arc<WorkspacePromptProjectionCache>, prompt_projection_cache: Arc<WorkspacePromptProjectionCache>,
runtime_id: Option<String>, runtime_id: Option<String>,
worker_mutation_identity: Option<RuntimeIdentityMaterial>, worker_mutation_identity: Option<RuntimeIdentityMaterial>,
runtime_request_audience: Option<String>, workspace_request_clients: Arc<HashMap<String, RuntimeWorkspaceRequestClient>>,
embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>, embedded_worker_mutation_dispatcher: Option<Arc<dyn EmbeddedWorkerMutationDispatcher>>,
controller_transport: WorkerControllerTransport, controller_transport: WorkerControllerTransport,
} }
@@ -339,7 +335,7 @@ impl ProfileRuntimeWorkerFactory {
prompt_projection_cache: Arc::new(WorkspacePromptProjectionCache::default()), prompt_projection_cache: Arc::new(WorkspacePromptProjectionCache::default()),
runtime_id: None, runtime_id: None,
worker_mutation_identity: None, worker_mutation_identity: None,
runtime_request_audience: None, workspace_request_clients: Arc::new(HashMap::new()),
embedded_worker_mutation_dispatcher: None, embedded_worker_mutation_dispatcher: None,
controller_transport: WorkerControllerTransport::UnixSocket, controller_transport: WorkerControllerTransport::UnixSocket,
} }
@@ -360,14 +356,10 @@ impl ProfileRuntimeWorkerFactory {
self self
} }
pub fn with_runtime_request_identity( pub fn with_workspace_request_client(mut self, client: RuntimeWorkspaceRequestClient) -> Self {
mut self, self.runtime_id = Some(client.runtime_id().to_string());
identity: RuntimeIdentityMaterial, Arc::make_mut(&mut self.workspace_request_clients)
audience: impl Into<String>, .insert(client.workspace_id().to_string(), client);
) -> Self {
self.runtime_id = Some(identity.identity_id.clone());
self.worker_mutation_identity = Some(identity);
self.runtime_request_audience = Some(audience.into());
self self
} }
@@ -550,7 +542,7 @@ impl RuntimeWorkspaceBackendRef {
worker_ref: &WorkerRef, worker_ref: &WorkerRef,
workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>, workspace_scope: Option<&crate::runtime::RuntimeWorkspaceScope>,
mutation_identity: Option<&RuntimeIdentityMaterial>, mutation_identity: Option<&RuntimeIdentityMaterial>,
runtime_request_audience: Option<&str>, workspace_request_client: Option<&RuntimeWorkspaceRequestClient>,
embedded_dispatcher: Option<&Arc<dyn EmbeddedWorkerMutationDispatcher>>, embedded_dispatcher: Option<&Arc<dyn EmbeddedWorkerMutationDispatcher>>,
prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>, prompt_projection_cache: Option<Arc<WorkspacePromptProjectionCache>>,
) -> WorkerWorkspaceContext { ) -> WorkerWorkspaceContext {
@@ -561,28 +553,33 @@ impl RuntimeWorkspaceBackendRef {
base_url, base_url,
runtime_id, runtime_id,
} => { } => {
let mut client = RuntimeOwnedWorkspaceClient::new( let mut client = workspace_request_client
workspace_id.clone(), .cloned()
base_url.clone(), .map(|request_client| {
runtime_id.clone(), RuntimeOwnedWorkspaceClient::from_request_client(
worker_ref.worker_id.to_string(), request_client,
); worker_ref.worker_id.to_string(),
)
})
.unwrap_or_else(|| {
RuntimeOwnedWorkspaceClient::new(
workspace_id.clone(),
base_url.clone(),
runtime_id.clone(),
worker_ref.worker_id.to_string(),
)
});
if let Some(cache) = prompt_projection_cache { if let Some(cache) = prompt_projection_cache {
client = client.with_prompt_projection_cache(cache); client = client.with_prompt_projection_cache(cache);
} }
if let Some(identity) = mutation_identity { if let (Some(scope), Some(identity), Some(request_client)) =
let audience = runtime_request_audience (workspace_scope, mutation_identity, workspace_request_client)
.or_else(|| workspace_scope.map(|scope| scope.server_id.as_str())); {
if let Some(audience) = audience {
client = client.with_runtime_request_source(identity, audience.to_owned());
}
}
if let (Some(scope), Some(identity)) = (workspace_scope, mutation_identity) {
client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote( client = client.with_worker_remove(RuntimeWorkerMutationForwarder::remote(
identity, identity,
scope.clone(), scope.clone(),
worker_ref.worker_id.to_string(), worker_ref.worker_id.to_string(),
base_url.clone(), request_client.clone(),
)); ));
} else if let (Some(scope), Some(dispatcher)) = } else if let (Some(scope), Some(dispatcher)) =
(workspace_scope, embedded_dispatcher) (workspace_scope, embedded_dispatcher)
@@ -606,10 +603,18 @@ impl RuntimeWorkspaceBackendRef {
#[cfg(feature = "http-server")] #[cfg(feature = "http-server")]
async fn fetch_workspace_config_http( async fn fetch_workspace_config_http(
request: &WorkspaceConfigFetchRequest, request: &WorkspaceConfigFetchRequest,
identity: Option<&RuntimeIdentityMaterial>, client: &RuntimeWorkspaceRequestClient,
audience: Option<&str>,
) -> Result<WorkspaceConfigFetchResult, String> { ) -> Result<WorkspaceConfigFetchResult, String> {
let mut url = reqwest::Url::parse(&request.workspace_api.base_url) if !client.matches_workspace(
&request.workspace_api.workspace_id,
&request.workspace_api.base_url,
) {
return Err(format!(
"Workspace request route does not match Workspace Config source: workspace={} base_url={}",
request.workspace_api.workspace_id, request.workspace_api.base_url
));
}
let mut url = reqwest::Url::parse(client.base_url())
.map_err(|error| format!("Workspace API base URL is invalid: {error}"))?; .map_err(|error| format!("Workspace API base URL is invalid: {error}"))?;
url.set_path(&format!( url.set_path(&format!(
"/api/w/{}/runtime-config", "/api/w/{}/runtime-config",
@@ -620,79 +625,48 @@ async fn fetch_workspace_config_http(
| crate::catalog::ProfileSelector::Named(value) => value.clone(), | crate::catalog::ProfileSelector::Named(value) => value.clone(),
}; };
url.query_pairs_mut().append_pair("profile", &profile); url.query_pairs_mut().append_pair("profile", &profile);
let mut headers = reqwest::header::HeaderMap::new();
let path = url.path().to_owned();
let request_target = match url.query() {
Some(query) => format!("{path}?{query}"),
None => path.clone(),
};
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(3))
.timeout(WORKSPACE_CONFIG_HTTP_TIMEOUT)
.build()
.map_err(|error| format!("failed to build Workspace Config HTTP client: {error}"))?;
let mut http_request = client.get(url);
if let Some(identity) = identity {
let audience = audience
.ok_or_else(|| "Workspace Config request proof audience is unavailable".to_owned())?;
let proof = RuntimeRequestSourceSigner::from_identity(identity)
.issue(
audience,
&request.workspace_api.workspace_id,
None,
BACKEND_RESOURCE_FETCH_PERMISSION,
"GET",
&request_target,
b"",
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.map_err(|error| error.to_string())?;
http_request = http_request.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, proof);
}
if let Some(cached) = request.cached.as_ref() { if let Some(cached) = request.cached.as_ref() {
http_request = http_request.header( headers.insert(
reqwest::header::IF_NONE_MATCH, reqwest::header::IF_NONE_MATCH,
workspace_config_etag(&cached.digest), reqwest::header::HeaderValue::from_str(&workspace_config_etag(&cached.digest))
.map_err(|error| format!("Workspace Config ETag is invalid: {error}"))?,
); );
} }
let mut path_and_query = url.path().to_string();
let response = http_request if let Some(query) = url.query() {
.send() path_and_query.push('?');
path_and_query.push_str(query);
}
let response = client
.execute(RuntimeWorkspaceRequest {
method: reqwest::Method::GET,
path_and_query,
body: Vec::new(),
headers,
permission: BACKEND_RESOURCE_FETCH_PERMISSION.to_string(),
worker_id: None,
timeout: Some(WORKSPACE_CONFIG_HTTP_TIMEOUT),
max_response_bytes: MAX_WORKSPACE_CONFIG_RESPONSE_BYTES,
})
.await .await
.map_err(|error| format!("failed to fetch latest Workspace Config: {error}"))?; .map_err(|error| format!("failed to fetch latest Workspace Config: {error}"))?;
if response.status() == reqwest::StatusCode::NOT_MODIFIED { if response.status == reqwest::StatusCode::NOT_MODIFIED {
return Ok(WorkspaceConfigFetchResult::NotModified); return Ok(WorkspaceConfigFetchResult::NotModified);
} }
if !response.status().is_success() { if !response.status.is_success() {
return Err(format!( return Err(format!(
"latest Workspace Config fetch failed with HTTP {}", "latest Workspace Config fetch failed with HTTP {}",
response.status() response.status
)); ));
} }
if response
.content_length()
.is_some_and(|size| size > MAX_WORKSPACE_CONFIG_RESPONSE_BYTES as u64)
{
return Err("latest Workspace Config response exceeds the size limit".to_string());
}
let response_etag = response let response_etag = response
.headers() .headers
.get(reqwest::header::ETAG) .get(reqwest::header::ETAG)
.and_then(|value| value.to_str().ok()) .and_then(|value| value.to_str().ok())
.map(str::to_string) .map(str::to_string)
.ok_or_else(|| "latest Workspace Config response is missing its ETag".to_string())?; .ok_or_else(|| "latest Workspace Config response is missing its ETag".to_string())?;
let mut body = Vec::new(); let bundle = serde_json::from_slice::<ConfigBundle>(&response.body)
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk =
chunk.map_err(|error| format!("failed to read latest Workspace Config: {error}"))?;
if body.len().saturating_add(chunk.len()) > MAX_WORKSPACE_CONFIG_RESPONSE_BYTES {
return Err("latest Workspace Config response exceeds the size limit".to_string());
}
body.extend_from_slice(&chunk);
}
let bundle = serde_json::from_slice::<ConfigBundle>(&body)
.map_err(|error| format!("failed to decode latest Workspace Config: {error}"))?; .map_err(|error| format!("failed to decode latest Workspace Config: {error}"))?;
let expected_etag = workspace_config_etag(&bundle.metadata.digest); let expected_etag = workspace_config_etag(&bundle.metadata.digest);
if response_etag != expected_etag { if response_etag != expected_etag {
@@ -704,10 +678,9 @@ async fn fetch_workspace_config_http(
} }
#[cfg(not(feature = "http-server"))] #[cfg(not(feature = "http-server"))]
async fn fetch_workspace_config_http( async fn fetch_workspace_config_http<T>(
_request: &WorkspaceConfigFetchRequest, _request: &WorkspaceConfigFetchRequest,
_identity: Option<&RuntimeIdentityMaterial>, _client: &T,
_audience: Option<&str>,
) -> Result<WorkspaceConfigFetchResult, String> { ) -> Result<WorkspaceConfigFetchResult, String> {
Err("Workspace Config fetch requires the worker-runtime http-server feature".to_string()) Err("Workspace Config fetch requires the worker-runtime http-server feature".to_string())
} }
@@ -805,12 +778,16 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
&self, &self,
request: WorkspaceConfigFetchRequest, request: WorkspaceConfigFetchRequest,
) -> Result<WorkspaceConfigFetchResult, String> { ) -> Result<WorkspaceConfigFetchResult, String> {
fetch_workspace_config_http( let client = self
&request, .workspace_request_clients
self.worker_mutation_identity.as_ref(), .get(&request.workspace_api.workspace_id)
self.runtime_request_audience.as_deref(), .ok_or_else(|| {
) format!(
.await "Workspace request client is unavailable for workspace {}",
request.workspace_api.workspace_id
)
})?;
fetch_workspace_config_http(&request, client).await
} }
fn observe_workspace_prompt_projection( fn observe_workspace_prompt_projection(
@@ -854,11 +831,16 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
.map(|api| api.workspace_id.clone()); .map(|api| api.workspace_id.clone());
let observation_grants = request.request.worker_observation_grants.clone(); let observation_grants = request.request.worker_observation_grants.clone();
let observation_enabled = request.request.worker_observation_enabled; let observation_enabled = request.request.worker_observation_enabled;
let workspace_request_client = request
.request
.workspace_api
.as_ref()
.and_then(|api| self.workspace_request_clients.get(&api.workspace_id));
let workspace_context = workspace_backend_ref.worker_context( let workspace_context = workspace_backend_ref.worker_context(
&request.worker_ref, &request.worker_ref,
request.workspace_scope.as_ref(), request.workspace_scope.as_ref(),
self.worker_mutation_identity.as_ref(), self.worker_mutation_identity.as_ref(),
self.runtime_request_audience.as_deref(), workspace_request_client,
self.embedded_worker_mutation_dispatcher.as_ref(), self.embedded_worker_mutation_dispatcher.as_ref(),
Some(self.prompt_projection_cache.clone()), Some(self.prompt_projection_cache.clone()),
); );
@@ -1039,11 +1021,16 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
.map(|api| api.workspace_id.clone()); .map(|api| api.workspace_id.clone());
let observation_grants = request.request.worker_observation_grants.clone(); let observation_grants = request.request.worker_observation_grants.clone();
let observation_enabled = request.request.worker_observation_enabled; let observation_enabled = request.request.worker_observation_enabled;
let workspace_request_client = request
.request
.workspace_api
.as_ref()
.and_then(|api| self.workspace_request_clients.get(&api.workspace_id));
let workspace_context = workspace_backend_ref.worker_context( let workspace_context = workspace_backend_ref.worker_context(
&request.worker_ref, &request.worker_ref,
request.workspace_scope.as_ref(), request.workspace_scope.as_ref(),
self.worker_mutation_identity.as_ref(), self.worker_mutation_identity.as_ref(),
self.runtime_request_audience.as_deref(), workspace_request_client,
self.embedded_worker_mutation_dispatcher.as_ref(), self.embedded_worker_mutation_dispatcher.as_ref(),
Some(self.prompt_projection_cache.clone()), Some(self.prompt_projection_cache.clone()),
); );
@@ -1548,6 +1535,10 @@ where
)); ));
} }
}; };
let connected_worker_state = worker_state
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
workers.insert( workers.insert(
worker_ref.clone(), worker_ref.clone(),
RuntimeWorkerExecution { RuntimeWorkerExecution {
@@ -1560,6 +1551,7 @@ where
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()), handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
worker_state: connected_worker_state,
working_directory: working_directory.map(|binding| binding.status()), working_directory: working_directory.map(|binding| binding.status()),
} }
} }
@@ -2236,14 +2228,14 @@ mod tests {
use crate::catalog::{ use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, MaterializerKind, ProfileSelector, ConfigBundleRef, CreateWorkerRequest, MaterializerKind, ProfileSelector,
RepositorySelector, WorkingDirectoryClaim, WorkingDirectoryRepository, RepositorySelector, WorkingDirectoryClaim, WorkingDirectoryRepository,
WorkingDirectoryRequest, WorkingDirectoryRequest, WorkspaceApiRef,
}; };
use crate::execution::WorkerExecutionContext; use crate::execution::WorkerExecutionContext;
use crate::identity::WorkerId; use crate::identity::WorkerId;
use crate::identity::WorkerRef; use crate::identity::WorkerRef;
use crate::management::RuntimeOptions; use crate::management::RuntimeOptions;
use crate::observation::WorkerObservationCursor; use crate::observation::WorkerObservationCursor;
use crate::working_directory::RuntimeGitCacheMaterializer; use crate::working_directory::RuntimeGitMaterializer;
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};
use agen::llm_client::{ClientError, LlmClient, Request}; use agen::llm_client::{ClientError, LlmClient, Request};
@@ -2252,6 +2244,115 @@ mod tests {
use manifest::{Scope, WorkerManifest}; use manifest::{Scope, WorkerManifest};
use session_store::{LogEntry, WorkerMetadataStore}; use session_store::{LogEntry, WorkerMetadataStore};
#[test]
fn profile_factory_routes_workspace_requests_by_workspace_id() {
let profiles = tempfile::tempdir().unwrap();
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let factory = ProfileRuntimeWorkerFactory::new(profiles.path())
.with_workspace_request_client(
RuntimeWorkspaceRequestClient::new(
"workspace-a",
"https://workspace-a.example.test",
"runtime-a",
)
.with_runtime_request_source(&identity, "server-a"),
)
.with_workspace_request_client(
RuntimeWorkspaceRequestClient::new(
"workspace-b",
"https://workspace-b.example.test",
"runtime-a",
)
.with_runtime_request_source(&identity, "server-b"),
);
assert_eq!(
factory
.workspace_request_clients
.get("workspace-a")
.and_then(RuntimeWorkspaceRequestClient::audience),
Some("server-a")
);
assert_eq!(
factory
.workspace_request_clients
.get("workspace-b")
.and_then(RuntimeWorkspaceRequestClient::audience),
Some("server-b")
);
}
#[tokio::test]
async fn workspace_config_refresh_uses_workspace_scoped_request_client() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
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 (mut stream, _) = listener.accept().await.unwrap();
let mut request = Vec::new();
let mut chunk = [0_u8; 1024];
loop {
let read = stream.read(&mut chunk).await.unwrap();
if read == 0 {
break;
}
request.extend_from_slice(&chunk[..read]);
if request.windows(4).any(|window| window == b"\r\n\r\n") {
break;
}
}
stream
.write_all(b"HTTP/1.1 304 Not Modified\r\nConnection: close\r\n\r\n")
.await
.unwrap();
String::from_utf8(request).unwrap()
});
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let base_url = format!("http://{address}");
let client =
RuntimeWorkspaceRequestClient::new("workspace-b", base_url.clone(), "runtime-a")
.with_runtime_request_source(&identity, "server-b");
let bundle = test_bundle();
let bundle_ref = ConfigBundleRef {
id: bundle.metadata.id.clone(),
digest: bundle.metadata.digest.clone(),
};
let request = WorkspaceConfigFetchRequest {
workspace_api: WorkspaceApiRef {
workspace_id: "workspace-b".to_string(),
base_url,
},
profile: crate::catalog::ProfileSelector::Named("coder".to_string()),
expected: bundle_ref.clone(),
cached: Some(bundle_ref),
};
let result = fetch_workspace_config_http(&request, &client)
.await
.unwrap();
assert!(matches!(result, WorkspaceConfigFetchResult::NotModified));
let raw_request = server.await.unwrap();
let proof = raw_request
.lines()
.find_map(|line| {
line.split_once(':').and_then(|(name, value)| {
name.eq_ignore_ascii_case(crate::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
.then(|| value.trim().to_string())
})
})
.unwrap();
let claims = crate::auth::decode_runtime_request_source_claims(&proof).unwrap();
assert_eq!(claims.aud, "server-b");
assert_eq!(claims.workspace_id, "workspace-b");
assert_eq!(claims.worker_id, None);
assert_eq!(claims.method, "GET");
assert_eq!(
claims.path,
"/api/w/workspace-b/runtime-config?profile=coder"
);
}
fn test_command() -> WorkerCommandEnvelope { fn test_command() -> WorkerCommandEnvelope {
WorkerCommandEnvelope { WorkerCommandEnvelope {
command_id: 1, command_id: 1,
@@ -2987,13 +3088,13 @@ mod tests {
source_fingerprint: "sha256:test".to_string(), source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")), selector: Some(RepositorySelector::from("HEAD")),
}, },
materializer: MaterializerKind::RuntimeGitCache, materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: None, backend_workdir_id: None,
materialization: None, materialization: None,
} }
} }
fn materialized_worktree_root( fn materialized_clone_root(
runtime_base: &std::path::Path, runtime_base: &std::path::Path,
working_directory_id: &str, working_directory_id: &str,
) -> PathBuf { ) -> PathBuf {
@@ -3729,9 +3830,7 @@ mod tests {
}; };
let backend = WorkerRuntimeExecutionBackend::new(factory) let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap() .unwrap()
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new( .with_working_directory_materializer(RuntimeGitMaterializer::new(runtime_base.path()));
runtime_base.path(),
));
let runtime = let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend)) EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap(); .unwrap();
@@ -3968,9 +4067,7 @@ mod tests {
}; };
let backend = WorkerRuntimeExecutionBackend::new(factory) let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap() .unwrap()
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new( .with_working_directory_materializer(RuntimeGitMaterializer::new(runtime_base.path()));
runtime_base.path(),
));
let runtime = let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend)) EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap(); .unwrap();
@@ -3985,13 +4082,13 @@ mod tests {
.summary .summary
.working_directory_id .working_directory_id
.clone(); .clone();
let worktree_root = materialized_worktree_root(runtime_base.path(), &workdir_id); let clone_root = materialized_clone_root(runtime_base.path(), &workdir_id);
assert!(worktree_root.join("README.md").exists()); assert!(clone_root.join("README.md").exists());
runtime.stop_worker(&detail.worker_ref, None).unwrap(); runtime.stop_worker(&detail.worker_ref, None).unwrap();
runtime.delete_worker(&detail.worker_ref).unwrap(); runtime.delete_worker(&detail.worker_ref).unwrap();
assert!(worktree_root.join("README.md").exists()); assert!(clone_root.join("README.md").exists());
let status = runtime.working_directory(&workdir_id).unwrap(); let status = runtime.working_directory(&workdir_id).unwrap();
assert_eq!( assert_eq!(
status.summary.status, status.summary.status,
@@ -4007,9 +4104,7 @@ mod tests {
let repo = create_clean_repo(); let repo = create_clean_repo();
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory) let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap() .unwrap()
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new( .with_working_directory_materializer(RuntimeGitMaterializer::new(runtime_base.path()));
runtime_base.path(),
));
let runtime = let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend)) EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap(); .unwrap();
@@ -4018,8 +4113,8 @@ mod tests {
.create_working_directory(working_directory_request(repo.path())) .create_working_directory(working_directory_request(repo.path()))
.unwrap(); .unwrap();
let workdir_id = status.summary.working_directory_id.clone(); let workdir_id = status.summary.working_directory_id.clone();
let worktree_root = materialized_worktree_root(runtime_base.path(), &workdir_id); let clone_root = materialized_clone_root(runtime_base.path(), &workdir_id);
assert!(worktree_root.join("README.md").exists()); assert!(clone_root.join("README.md").exists());
let mut request = create_request("chat"); let mut request = create_request("chat");
request.working_directory = Some(WorkingDirectoryClaim { request.working_directory = Some(WorkingDirectoryClaim {
working_directory_id: workdir_id.clone(), working_directory_id: workdir_id.clone(),
@@ -4029,7 +4124,7 @@ mod tests {
let error = runtime.create_worker(request).unwrap_err(); let error = runtime.create_worker(request).unwrap_err();
assert!(format!("{error:?}").contains("spawn failed")); assert!(format!("{error:?}").contains("spawn failed"));
assert!(worktree_root.join("README.md").exists()); assert!(clone_root.join("README.md").exists());
let status = runtime.working_directory(&workdir_id).unwrap(); let status = runtime.working_directory(&workdir_id).unwrap();
assert_eq!( assert_eq!(
status.summary.status, status.summary.status,
@@ -4043,9 +4138,7 @@ mod tests {
let repo = create_clean_repo(); let repo = create_clean_repo();
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory) let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap() .unwrap()
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new( .with_working_directory_materializer(RuntimeGitMaterializer::new(runtime_base.path()));
runtime_base.path(),
));
let runtime = let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend)) EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap(); .unwrap();
@@ -4066,6 +4159,6 @@ mod tests {
}) })
.unwrap_or(0); .unwrap_or(0);
assert_eq!(remaining_workdirs, 0); assert_eq!(remaining_workdirs, 0);
assert!(working_directories_root.join(".repository-cache").is_dir()); assert!(!working_directories_root.join(".repository-cache").exists());
} }
} }
+116 -221
View File
@@ -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(&current_text) {
details.push_str(": ");
details.push_str(&current_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"));
}
}
-3
View File
@@ -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 }
+1 -6
View File
@@ -699,6 +699,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)?;
@@ -1385,12 +1386,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
+3
View File
@@ -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"),
} }
+4
View File
@@ -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(),
} }
} }
+25 -1
View File
@@ -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
+4
View File
@@ -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();
-2
View File
@@ -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;
@@ -681,7 +680,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;
+1
View File
@@ -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(),
} }
} }
+4
View File
@@ -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,
+69 -12
View File
@@ -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 =
+3 -74
View File
@@ -7256,6 +7256,7 @@ fn delegated_scope_rule_to_scope_rule(rule: WorkerSpawnedScopeRule) -> Option<Sc
target: rule.target, target: rule.target,
permission, permission,
recursive: rule.recursive, recursive: rule.recursive,
symlink_policy: rule.symlink_policy,
}) })
} }
@@ -7508,6 +7509,7 @@ mod spawned_context_tests {
target: cwd.clone(), target: cwd.clone(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
@@ -7544,6 +7546,7 @@ mod spawned_context_tests {
target: workspace_root.clone(), target: workspace_root.clone(),
permission: Permission::Read, permission: Permission::Read,
recursive: true, recursive: true,
symlink_policy: Default::default(),
}], }],
deny: Vec::new(), deny: Vec::new(),
}, },
@@ -7750,80 +7753,6 @@ permission = "read"
}) })
); );
} }
#[test]
fn plugin_resolved_manifest_snapshot_is_persisted_without_profile() {
let mut manifest = WorkerManifest::from_toml(
r#"
[worker]
name = "plugin-snapshot"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[engine]
instruction = "saved"
[[scope.allow]]
target = "/snapshot/workspace"
permission = "read"
"#,
)
.unwrap();
assert!(manifest.profile.is_none());
assert!(
worker_metadata_for_manifest(&manifest, None, None, None)
.resolved_manifest_snapshot
.is_none()
);
manifest.plugins.resolved = vec![manifest::plugin::ResolvedPluginRecord {
identity: manifest::plugin::SourceQualifiedPluginId::new(
manifest::plugin::PluginSourceKind::Project,
"example",
),
source: manifest::plugin::PluginSourceKind::Project,
package_path: PathBuf::from("/snapshot/workspace/.yoi/plugins/example.yoi-plugin"),
package_label: "example.yoi-plugin".to_string(),
digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
.to_string(),
version: "0.1.0".to_string(),
manifest: manifest::plugin::PluginPackageManifest {
schema_version: 1,
id: "example".to_string(),
name: "Example".to_string(),
version: "0.1.0".to_string(),
description: None,
surfaces: vec![manifest::plugin::PluginSurface::Hook],
runtime: None,
hooks: vec![],
tools: vec![],
services: vec![],
ingresses: vec![],
permissions: vec![],
request: vec![],
websocket: vec![],
},
enabled_surfaces: vec![manifest::plugin::PluginSurface::Hook],
grants: manifest::plugin::PluginGrantConfig::default(),
config: None,
}];
let metadata = worker_metadata_for_manifest(&manifest, None, None, None);
let snapshot = metadata
.resolved_manifest_snapshot
.expect("plugin-resolved manifest should be snapshotted");
let restored = manifest::read_persisted_worker_manifest_snapshot(snapshot).unwrap();
assert!(restored.profile.is_none());
assert_eq!(restored.plugins.resolved.len(), 1);
assert_eq!(
restored.plugins.resolved[0].digest,
"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
assert_eq!(restored.plugins.resolved[0].version, "0.1.0");
}
} }
#[cfg(test)] #[cfg(test)]
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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"] }
+12 -9
View File
@@ -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
-355
View File
@@ -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"));
}
}
+313 -2
View File
@@ -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,
@@ -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,
+3 -4
View File
@@ -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
View File
@@ -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`")
);
}
} }
} }
+47 -141
View File
@@ -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"; }}; }}"#)
} }
+38 -6
View File
@@ -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 {
+619 -19
View File
@@ -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 [
+11 -2
View File
@@ -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)
@@ -10,12 +10,11 @@ use protocol::subscription::{
SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, SubscriptionTerminationCode, SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, SubscriptionTerminationCode,
}; };
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims}; use tokio_tungstenite::{client_async_tls_with_config, connect_async};
use crate::hosts::RemoteRuntimeConfig; use crate::hosts::{RemoteRuntimeConfig, resolve_strict_remote_runtime_endpoint};
const DOWNSTREAM_QUEUE_CAPACITY: usize = 256; const DOWNSTREAM_QUEUE_CAPACITY: usize = 256;
const RECONNECT_DELAY: Duration = Duration::from_millis(100); const RECONNECT_DELAY: Duration = Duration::from_millis(100);
@@ -918,10 +917,43 @@ async fn connect_runtime(
.map_err(|error| format!("invalid Runtime authorization header: {error}"))?, .map_err(|error| format!("invalid Runtime authorization header: {error}"))?,
); );
} }
connect_async(request) if config.strict_public_egress {
let base_url = config.base_url.clone();
let (_, addresses) =
tokio::task::spawn_blocking(move || resolve_strict_remote_runtime_endpoint(&base_url))
.await
.map_err(|_| {
"Runtime subscription endpoint resolution task failed".to_string()
})??;
let stream = tokio::time::timeout(config.timeout, async move {
let mut last_error = None;
for address in addresses {
match tokio::net::TcpStream::connect(address).await {
Ok(stream) => return Ok(stream),
Err(error) => last_error = Some(error),
}
}
Err(last_error
.map(|error| error.to_string())
.unwrap_or_else(|| "no validated Runtime address was available".to_string()))
})
.await .await
.map_err(|_| "Runtime subscription TCP connection timed out".to_string())??;
tokio::time::timeout(
config.timeout,
client_async_tls_with_config(request, stream, None, None),
)
.await
.map_err(|_| "Runtime subscription TLS/WebSocket handshake timed out".to_string())?
.map(|(socket, _)| socket) .map(|(socket, _)| socket)
.map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}")) .map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}"))
} else {
tokio::time::timeout(config.timeout, connect_async(request))
.await
.map_err(|_| "Runtime subscription connection timed out".to_string())?
.map(|(socket, _)| socket)
.map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}"))
}
} }
fn runtime_endpoint(base_url: &str) -> String { fn runtime_endpoint(base_url: &str) -> String {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
@@ -935,24 +967,15 @@ fn runtime_endpoint(base_url: &str) -> String {
} }
fn runtime_token( fn runtime_token(
config: &RemoteRuntimeConfig, config: &RemoteRuntimeConfig,
workspace_id: &str, _workspace_id: &str,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
let Some(auth) = config.auth.as_ref() else { if let Some(authorization) = config.workspace_authorization.as_ref() {
return Ok(config.bearer_token.clone()); return authorization
}; .issue("GET", "/v1/protocol/ws", "workers:list", None, &[])
let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key); .map(Some)
let claims = capability_claims( .map_err(|error| error.message);
&auth.server_id, }
&config.runtime_id, Ok(config.bearer_token.clone())
workspace_id,
vec!["workers:list".into()],
300,
)
.map_err(|error| error.to_string())?;
signer
.sign(&claims)
.map(Some)
.map_err(|error| error.to_string())
} }
fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) { fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) {
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus { *status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
@@ -19,6 +19,10 @@ impl WorkerExecutionBackend for TestExecutionBackend {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::connected( WorkerExecutionSpawnResult::connected(
WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
None, None,
) )
} }
@@ -174,12 +178,18 @@ async fn equal_downstream_selectors_share_one_upstream_subscription() {
.await; .await;
assert_eq!(status.desired_selectors, 1); assert_eq!(status.desired_selectors, 1);
let mut running = worker
.worker_state
.clone()
.expect("connected test Worker must expose its initial state");
running.revision += 1;
running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
));
runtime runtime
.observe_worker_event( .observe_worker_event(
&worker.worker_ref, &worker.worker_ref,
protocol::Event::WorkerState { protocol::Event::WorkerState { snapshot: running },
snapshot: protocol::WorkerStatus::Running.into(),
},
) )
.unwrap(); .unwrap();
for subscription in [&mut first, &mut second] { for subscription in [&mut first, &mut second] {
@@ -334,12 +344,18 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
workers[0].runtime_id.as_deref(), workers[0].runtime_id.as_deref(),
Some("embedded-worker-runtime") Some("embedded-worker-runtime")
); );
let mut running = worker
.worker_state
.clone()
.expect("connected test Worker must expose its initial state");
running.revision += 1;
running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
));
runtime runtime
.observe_worker_event( .observe_worker_event(
&worker.worker_ref, &worker.worker_ref,
protocol::Event::WorkerState { protocol::Event::WorkerState { snapshot: running },
snapshot: protocol::WorkerStatus::Running.into(),
},
) )
.unwrap(); .unwrap();
assert!(matches!(next_event(&mut subscription).await, assert!(matches!(next_event(&mut subscription).await,
@@ -416,3 +432,19 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
)); ));
server.abort(); server.abort();
} }
#[tokio::test]
async fn strict_runtime_subscription_rejects_private_endpoint_before_websocket_connect() {
let config = RemoteRuntimeConfig::new(
"runtime-private",
"Private Runtime",
"https://169.254.169.254",
None,
)
.with_strict_public_egress(true);
let error = match connect_runtime(&config, "workspace-a").await {
Err(error) => error,
Ok(_) => panic!("private endpoint unexpectedly produced a WebSocket"),
};
assert!(error.contains("endpoint host is not public"), "{error}");
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,15 @@
use rusqlite::{OptionalExtension, TransactionBehavior, params}; use rusqlite::{OptionalExtension, TransactionBehavior, params};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use crate::store::WorkdirCreateOperationRecord; use crate::store::{
WorkdirCreateCredentialCandidate, WorkdirCreateCredentialCandidateRole,
WorkdirCreateOperationRecord,
};
use crate::{Error, Result, SqliteWorkspaceStore}; use crate::{Error, Result, SqliteWorkspaceStore};
const MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES: usize = 2;
const MAX_CREDENTIAL_ID_BYTES: usize = 128;
pub fn selector_for_retry( pub fn selector_for_retry(
explicit_selector: Option<&str>, explicit_selector: Option<&str>,
persisted_selector: Option<&str>, persisted_selector: Option<&str>,
@@ -172,11 +178,17 @@ impl SqliteWorkspaceStore {
host_trust_id: &str, host_trust_id: &str,
host_trust_revision: u64, host_trust_revision: u64,
repository_access_mode: &str, repository_access_mode: &str,
cache_generation: u64, credential_candidates: &[WorkdirCreateCredentialCandidate],
now: &str, now: &str,
) -> Result<WorkdirCreateOperationRecord> { ) -> Result<WorkdirCreateOperationRecord> {
validate_workdir_create_credential_candidates(
credential_id,
credential_revision,
credential_candidates,
)?;
self.with_conn_mut(|conn| { self.with_conn_mut(|conn| {
let operation = read_workdir_create_operation(conn, workspace_id, operation_id)? let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let operation = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| { .ok_or_else(|| {
Error::RegistryInconsistency(format!( Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared before Repository access binding" "Workdir create operation `{operation_id}` disappeared before Repository access binding"
@@ -194,7 +206,7 @@ impl SqliteWorkspaceStore {
|| operation.host_trust_revision != Some(host_trust_revision) || operation.host_trust_revision != Some(host_trust_revision)
|| operation.repository_access_mode.as_deref() || operation.repository_access_mode.as_deref()
!= Some(repository_access_mode) != Some(repository_access_mode)
|| operation.cache_generation != cache_generation || operation.credential_candidates != credential_candidates
{ {
return Err(Error::InvalidInput(format!( return Err(Error::InvalidInput(format!(
"Workdir create operation `{operation_id}` Repository access evidence changed" "Workdir create operation `{operation_id}` Repository access evidence changed"
@@ -202,12 +214,11 @@ impl SqliteWorkspaceStore {
} }
return Ok(operation); return Ok(operation);
} }
conn.execute( let updated = tx.execute(
r#"UPDATE workdir_create_operations r#"UPDATE workdir_create_operations
SET credential_id = ?4, credential_revision = ?5, SET credential_id = ?4, credential_revision = ?5,
host_trust_id = ?6, host_trust_revision = ?7, host_trust_id = ?6, host_trust_revision = ?7,
repository_access_mode = ?8, cache_generation = ?9, repository_access_mode = ?8, updated_at = ?9
updated_at = ?10
WHERE workspace_id = ?1 AND operation_id = ?2 WHERE workspace_id = ?1 AND operation_id = ?2
AND request_fingerprint = ?3 AND credential_id IS NULL"#, AND request_fingerprint = ?3 AND credential_id IS NULL"#,
params![ params![
@@ -223,17 +234,63 @@ impl SqliteWorkspaceStore {
"host-trust revision is out of range".to_string() "host-trust revision is out of range".to_string()
))?, ))?,
repository_access_mode, repository_access_mode,
i64::try_from(cache_generation).map_err(|_| Error::InvalidInput(
"cache generation is out of range".to_string()
))?,
now, now,
], ],
)?; )?;
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| { if updated != 1 {
Error::RegistryInconsistency(format!( return Err(Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared after Repository access binding" "Workdir create operation `{operation_id}` changed before Repository access binding"
)) )));
}) }
for (ordinal, candidate) in credential_candidates.iter().enumerate() {
tx.execute(
r#"INSERT INTO workdir_create_credential_candidates (
workspace_id, operation_id, ordinal, role,
credential_id, credential_revision
) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
params![
workspace_id,
operation_id,
i64::try_from(ordinal).map_err(|_| Error::InvalidInput(
"credential candidate ordinal is out of range".to_string()
))?,
candidate.role.as_str(),
candidate.credential_id,
i64::try_from(candidate.credential_revision).map_err(|_| {
Error::InvalidInput(
"credential candidate revision is out of range".to_string(),
)
})?,
],
)?;
tx.execute(
r#"INSERT INTO workdir_create_credential_revision_retentions (
workspace_id, operation_id, ordinal,
credential_id, credential_revision
) VALUES (?1, ?2, ?3, ?4, ?5)"#,
params![
workspace_id,
operation_id,
i64::try_from(ordinal).map_err(|_| Error::InvalidInput(
"credential candidate ordinal is out of range".to_string()
))?,
candidate.credential_id,
i64::try_from(candidate.credential_revision).map_err(|_| {
Error::InvalidInput(
"credential candidate revision is out of range".to_string(),
)
})?,
],
)?;
}
let bound = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared after Repository access binding"
))
})?;
tx.commit()?;
Ok(bound)
}) })
} }
@@ -247,7 +304,8 @@ impl SqliteWorkspaceStore {
updated_at: &str, updated_at: &str,
) -> Result<WorkdirCreateOperationRecord> { ) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| { self.with_conn_mut(|conn| {
let changed = conn.execute( let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let changed = tx.execute(
r#"UPDATE workdir_create_operations r#"UPDATE workdir_create_operations
SET state = ?1, failure = ?2, updated_at = ?3 SET state = ?1, failure = ?2, updated_at = ?3
WHERE workspace_id = ?4 AND operation_id = ?5 WHERE workspace_id = ?4 AND operation_id = ?5
@@ -266,11 +324,21 @@ impl SqliteWorkspaceStore {
"Workdir create operation `{operation_id}` could not be finalized" "Workdir create operation `{operation_id}` could not be finalized"
))); )));
} }
read_workdir_create_operation(conn, workspace_id, operation_id)?.ok_or_else(|| { if succeeded {
Error::RegistryInconsistency(format!( tx.execute(
"Workdir create operation `{operation_id}` disappeared" r#"DELETE FROM workdir_create_credential_revision_retentions
)) WHERE workspace_id = ?1 AND operation_id = ?2"#,
}) params![workspace_id, operation_id],
)?;
}
let finished = read_workdir_create_operation(&tx, workspace_id, operation_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workdir create operation `{operation_id}` disappeared"
))
})?;
tx.commit()?;
Ok(finished)
}) })
} }
@@ -288,48 +356,157 @@ fn read_workdir_create_operation(
workspace_id: &str, workspace_id: &str,
operation_id: &str, operation_id: &str,
) -> Result<Option<WorkdirCreateOperationRecord>> { ) -> Result<Option<WorkdirCreateOperationRecord>> {
conn.query_row( let mut operation = conn
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector, .query_row(
r#"SELECT workspace_id, operation_id, request_fingerprint, repository_id, selector,
requested_runtime_id, resolved_runtime_id, config_revision, requested_runtime_id, resolved_runtime_id, config_revision,
config_projection_digest, source_kind, source_uri, source_revision, config_projection_digest, source_kind, source_uri, source_revision,
source_fingerprint, credential_id, credential_revision, source_fingerprint, credential_id, credential_revision,
host_trust_id, host_trust_revision, repository_access_mode, host_trust_id, host_trust_revision, repository_access_mode,
cache_generation, working_directory_id, state, failure, working_directory_id, state, failure,
created_at, updated_at created_at, updated_at
FROM workdir_create_operations FROM workdir_create_operations
WHERE workspace_id = ?1 AND operation_id = ?2"#, WHERE workspace_id = ?1 AND operation_id = ?2"#,
params![workspace_id, operation_id], params![workspace_id, operation_id],
|row| { |row| {
Ok(WorkdirCreateOperationRecord { Ok(WorkdirCreateOperationRecord {
workspace_id: row.get(0)?, workspace_id: row.get(0)?,
operation_id: row.get(1)?, operation_id: row.get(1)?,
request_fingerprint: row.get(2)?, request_fingerprint: row.get(2)?,
repository_id: row.get(3)?, repository_id: row.get(3)?,
selector: row.get(4)?, selector: row.get(4)?,
requested_runtime_id: row.get(5)?, requested_runtime_id: row.get(5)?,
resolved_runtime_id: row.get(6)?, resolved_runtime_id: row.get(6)?,
config_revision: row.get::<_, i64>(7)? as u64, config_revision: row.get::<_, i64>(7)? as u64,
config_projection_digest: row.get(8)?, config_projection_digest: row.get(8)?,
source_kind: row.get(9)?, source_kind: row.get(9)?,
source_uri: row.get(10)?, source_uri: row.get(10)?,
source_revision: row.get::<_, Option<i64>>(11)?.map(|value| value as u64), source_revision: row.get::<_, Option<i64>>(11)?.map(|value| value as u64),
source_fingerprint: row.get(12)?, source_fingerprint: row.get(12)?,
credential_id: row.get(13)?, credential_id: row.get(13)?,
credential_revision: row.get::<_, Option<i64>>(14)?.map(|value| value as u64), credential_revision: row.get::<_, Option<i64>>(14)?.map(|value| value as u64),
host_trust_id: row.get(15)?, host_trust_id: row.get(15)?,
host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64), host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64),
repository_access_mode: row.get(17)?, repository_access_mode: row.get(17)?,
cache_generation: row.get::<_, i64>(18)? as u64, credential_candidates: Vec::new(),
working_directory_id: row.get(19)?, working_directory_id: row.get(18)?,
state: row.get(20)?, state: row.get(19)?,
failure: row.get(21)?, failure: row.get(20)?,
created_at: row.get(22)?, created_at: row.get(21)?,
updated_at: row.get(23)?, updated_at: row.get(22)?,
}) })
}, },
) )
.optional() .optional()?;
.map_err(Error::from)
if let Some(operation) = operation.as_mut() {
let mut statement = conn.prepare(
r#"SELECT ordinal, role, credential_id, credential_revision
FROM workdir_create_credential_candidates
WHERE workspace_id = ?1 AND operation_id = ?2
ORDER BY ordinal ASC"#,
)?;
let rows = statement.query_map(params![workspace_id, operation_id], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, i64>(3)?,
))
})?;
for (expected_ordinal, row) in rows.enumerate() {
let (ordinal, role, credential_id, credential_revision) = row?;
if ordinal
!= i64::try_from(expected_ordinal).map_err(|_| {
Error::Store(
"Workdir create credential candidate ordinal is out of range".to_string(),
)
})?
{
return Err(Error::Store(
"Workdir create credential candidate ordinals are not contiguous".to_string(),
));
}
operation
.credential_candidates
.push(WorkdirCreateCredentialCandidate {
role: WorkdirCreateCredentialCandidateRole::parse(&role)?,
credential_id,
credential_revision: u64::try_from(credential_revision).map_err(|_| {
Error::Store(format!(
"invalid Workdir create credential candidate revision `{credential_revision}`"
))
})?,
});
}
if !operation.credential_candidates.is_empty() {
validate_workdir_create_credential_candidates(
operation.credential_id.as_deref().unwrap_or_default(),
operation.credential_revision.unwrap_or_default(),
&operation.credential_candidates,
)
.map_err(|error| {
Error::Store(format!(
"invalid persisted Workdir create credential snapshot: {error}"
))
})?;
}
}
Ok(operation)
}
pub(crate) fn validate_workdir_create_credential_candidates(
credential_id: &str,
credential_revision: u64,
candidates: &[WorkdirCreateCredentialCandidate],
) -> Result<()> {
if candidates.is_empty() || candidates.len() > MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES {
return Err(Error::InvalidInput(format!(
"Workdir create credential candidates must contain 1..={MAX_WORKDIR_CREATE_CREDENTIAL_CANDIDATES} entries"
)));
}
if credential_id.is_empty() || credential_id.len() > MAX_CREDENTIAL_ID_BYTES {
return Err(Error::InvalidInput(
"Workdir create credential id is invalid".to_string(),
));
}
if credential_revision == 0 {
return Err(Error::InvalidInput(
"Workdir create credential revision must be greater than zero".to_string(),
));
}
let primary = &candidates[0];
if primary.role != WorkdirCreateCredentialCandidateRole::Primary
|| primary.credential_id != credential_id
|| primary.credential_revision != credential_revision
{
return Err(Error::InvalidInput(
"Workdir create primary credential evidence does not match the ordered candidate snapshot"
.to_string(),
));
}
if candidates.len() == 2
&& candidates[1].role != WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback
{
return Err(Error::InvalidInput(
"Workdir create fallback credential role is invalid".to_string(),
));
}
if candidates.iter().any(|candidate| {
candidate.credential_id.is_empty()
|| candidate.credential_id.len() > MAX_CREDENTIAL_ID_BYTES
|| candidate.credential_revision == 0
}) {
return Err(Error::InvalidInput(
"Workdir create credential candidate identity is invalid".to_string(),
));
}
if candidates.len() == 2 && candidates[0].credential_id == candidates[1].credential_id {
return Err(Error::InvalidInput(
"Workdir create credential candidates contain a duplicate credential id".to_string(),
));
}
Ok(())
} }
#[cfg(test)] #[cfg(test)]
@@ -354,7 +531,7 @@ mod tests {
} }
#[test] #[test]
fn retry_keeps_resolved_config_evidence_and_rejects_changed_input() { fn retry_keeps_resolved_config_and_credential_candidates_after_fallback_moves() {
let store = SqliteWorkspaceStore::in_memory().unwrap(); let store = SqliteWorkspaceStore::in_memory().unwrap();
futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord { futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace".to_string(), workspace_id: "workspace".to_string(),
@@ -410,7 +587,7 @@ mod tests {
host_trust_id: None, host_trust_id: None,
host_trust_revision: None, host_trust_revision: None,
repository_access_mode: None, repository_access_mode: None,
cache_generation: 0, credential_candidates: Vec::new(),
working_directory_id: "wd-1".to_string(), working_directory_id: "wd-1".to_string(),
state: "pending".to_string(), state: "pending".to_string(),
failure: None, failure: None,
@@ -421,6 +598,44 @@ mod tests {
store.reserve_workdir_create_operation(&record).unwrap(), store.reserve_workdir_create_operation(&record).unwrap(),
record record
); );
store
.with_conn_mut(|conn| {
for (credential_id, revision) in
[("credential-1", 3_i64), ("workspace-default-ssh", 7_i64)]
{
conn.execute(
r#"INSERT INTO repository_ssh_credentials (
workspace_id, credential_id, name,
public_key_algorithm, public_key_fingerprint,
current_revision, status, created_at
) VALUES ('workspace', ?1, ?1, 'ssh-ed25519', ?1, ?2,
'active', '2026-08-24T00:00:00Z')"#,
params![credential_id, revision],
)?;
conn.execute(
r#"INSERT INTO repository_ssh_credential_revisions (
workspace_id, credential_id, revision,
public_key_algorithm, public_key_fingerprint, created_at
) VALUES ('workspace', ?1, ?2, 'ssh-ed25519', ?1,
'2026-08-24T00:00:00Z')"#,
params![credential_id, revision],
)?;
}
Ok(())
})
.unwrap();
let candidates = vec![
WorkdirCreateCredentialCandidate {
role: WorkdirCreateCredentialCandidateRole::Primary,
credential_id: "credential-1".to_string(),
credential_revision: 3,
},
WorkdirCreateCredentialCandidate {
role: WorkdirCreateCredentialCandidateRole::WorkspaceDefaultFallback,
credential_id: "workspace-default-ssh".to_string(),
credential_revision: 7,
},
];
let bound = store let bound = store
.bind_workdir_create_repository_access( .bind_workdir_create_repository_access(
"workspace", "workspace",
@@ -431,14 +646,22 @@ mod tests {
"trust-1", "trust-1",
5, 5,
"read_only", "read_only",
2, &candidates,
"2026-08-24T00:00:01Z", "2026-08-24T00:00:01Z",
) )
.unwrap(); .unwrap();
assert_eq!(bound.credential_id.as_deref(), Some("credential-1")); assert_eq!(bound.credential_id.as_deref(), Some("credential-1"));
assert_eq!(bound.credential_revision, Some(3)); assert_eq!(bound.credential_revision, Some(3));
assert_eq!(bound.host_trust_revision, Some(5)); assert_eq!(bound.host_trust_revision, Some(5));
assert_eq!(bound.cache_generation, 2); assert_eq!(bound.credential_candidates, candidates);
let serialized = serde_json::to_string(&bound).unwrap();
assert!(serialized.contains("workspace_default_fallback"));
assert!(!serialized.contains("private_key"));
assert!(!serialized.contains("known_hosts"));
// A concurrent Workspace-default rotation must not replace the fallback
// revision already bound to this operation.
let mut changed_candidates = candidates.clone();
changed_candidates[1].credential_revision = 8;
assert!( assert!(
store store
.bind_workdir_create_repository_access( .bind_workdir_create_repository_access(
@@ -446,11 +669,11 @@ mod tests {
"call-1", "call-1",
&record.request_fingerprint, &record.request_fingerprint,
"credential-1", "credential-1",
4, 3,
"trust-1", "trust-1",
5, 5,
"read_only", "read_only",
2, &changed_candidates,
"2026-08-24T00:00:02Z", "2026-08-24T00:00:02Z",
) )
.is_err() .is_err()
@@ -486,6 +709,7 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(retry.state, "pending"); assert_eq!(retry.state, "pending");
assert_eq!(retry.failure, None); assert_eq!(retry.failure, None);
assert_eq!(retry.credential_candidates, candidates);
assert_eq!( assert_eq!(
store store
.load_workdir_create_operation("workspace", "call-1") .load_workdir_create_operation("workspace", "call-1")
+18 -2
View File
@@ -264,7 +264,9 @@ impl SqliteWorkspaceStore {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
let operation = let operation =
require_operation(&tx, workspace_id, operation_id, request_fingerprint)?; require_operation(&tx, workspace_id, operation_id, request_fingerprint)?;
if operation.state == WorkdirRemovalOperationState::Completed { if operation.state == WorkdirRemovalOperationState::Completed
&& operation.disposition == Some(WorkdirRemovalDisposition::Removed)
{
tx.commit()?; tx.commit()?;
return Ok(operation); return Ok(operation);
} }
@@ -1096,7 +1098,7 @@ mod tests {
host_trust_id: None, host_trust_id: None,
host_trust_revision: None, host_trust_revision: None,
repository_access_mode: None, repository_access_mode: None,
cache_generation: 0, credential_candidates: Vec::new(),
working_directory_id: "workdir-a".to_string(), working_directory_id: "workdir-a".to_string(),
state: "pending".to_string(), state: "pending".to_string(),
failure: None, failure: None,
@@ -1198,5 +1200,19 @@ mod tests {
.unwrap() .unwrap()
.is_some() .is_some()
); );
let retry = store
.begin_workdir_removal_attempt(
&retained.workspace_id,
&retained.operation_id,
&retained.request_fingerprint,
attempt_owner(),
)
.unwrap();
assert_eq!(retry.state, WorkdirRemovalOperationState::Pending);
assert_eq!(retry.attempt_count, 1);
assert_eq!(retry.disposition, None);
assert_eq!(retry.failure_category, None);
assert!(retry.retryable);
} }
} }
+13 -15
View File
@@ -10,7 +10,6 @@ use worker_runtime::auth::{
}; };
use worker_runtime::worker_source::InProcessWorkerMutationProof; use worker_runtime::worker_source::InProcessWorkerMutationProof;
use crate::hosts::RemoteRuntimeConfig;
use crate::server::{ServerConfig, WorkspaceApi}; use crate::server::{ServerConfig, WorkspaceApi};
use crate::store::ControlPlaneStore; use crate::store::ControlPlaneStore;
@@ -55,7 +54,7 @@ pub async fn verify_runtime_request_source_proof_with_store(
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> { ) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
let unverified = decode_runtime_request_source_claims(proof) let unverified = decode_runtime_request_source_claims(proof)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?; .map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, workspace_id)?; let audience = remote_audience(config, workspace_id)?;
let trusted = store let trusted = store
.get_workspace_runtime_binding(workspace_id, &unverified.iss) .get_workspace_runtime_binding(workspace_id, &unverified.iss)
.await .await
@@ -201,7 +200,7 @@ async fn verify_worker_remove_source_with(
PresentedWorkerMutationSourceProof::Remote(token) => { PresentedWorkerMutationSourceProof::Remote(token) => {
let unverified = decode_worker_mutation_source_claims(token) let unverified = decode_worker_mutation_source_claims(token)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?; .map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?; let audience = remote_audience(config, &config.workspace_id)?;
let trusted = store let trusted = store
.get_workspace_runtime_binding(&config.workspace_id, &unverified.iss) .get_workspace_runtime_binding(&config.workspace_id, &unverified.iss)
.await .await
@@ -357,20 +356,19 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
} }
fn remote_audience<'a>( fn remote_audience<'a>(
config: &'a crate::server::ServerConfig, config: &'a ServerConfig,
runtime_id: &str,
workspace_id: &str, workspace_id: &str,
) -> Result<std::borrow::Cow<'a, str>, WorkerMutationSourceProofError> { ) -> Result<&'a str, WorkerMutationSourceProofError> {
if runtime_id == crate::hosts::EMBEDDED_RUNTIME_ID {
return Ok(std::borrow::Cow::Owned(format!("embedded:{workspace_id}")));
}
config config
.remote_runtime_sources .backend_base_url
.iter() .as_deref()
.find(|runtime| runtime.runtime_id == runtime_id) .map(str::trim)
.and_then(|runtime: &RemoteRuntimeConfig| runtime.auth.as_ref()) .filter(|audience| !audience.is_empty())
.map(|auth| std::borrow::Cow::Borrowed(auth.server_id.as_str())) .ok_or_else(|| {
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust) WorkerMutationSourceProofError::Authority(format!(
"Backend public URL is unavailable for Workspace `{workspace_id}` source proof verification"
))
})
} }
fn validate_in_process_claims( fn validate_in_process_claims(
+225 -30
View File
@@ -1,38 +1,24 @@
use std::sync::Arc; use std::sync::Arc;
use chrono::{SecondsFormat, Utc}; use chrono::{SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
pub use workspace_api::{InitialRepositoryIntent, WorkspaceCreateRequest};
use workspace_api::{RepositoryObservedStatus, RepositorySource}; use workspace_api::{RepositoryObservedStatus, RepositorySource};
use crate::repository_source::{parse_repository_source, repository_source_fingerprint}; use crate::repository_source::{parse_repository_source, repository_source_fingerprint};
use crate::store::{ use crate::store::{
ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord, ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord,
}; };
use crate::workspace_signing_identity::{
WorkspaceSigningIdentityService, WorkspaceSigningMaterialStore,
};
use crate::{Error, Result}; use crate::{Error, Result};
const MAX_DISPLAY_NAME_BYTES: usize = 200; const MAX_DISPLAY_NAME_BYTES: usize = 200;
const MAX_OPERATION_KEY_BYTES: usize = 200; const MAX_OPERATION_KEY_BYTES: usize = 200;
#[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>,
}
#[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,
}
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct WorkspaceCreateResult { pub struct WorkspaceCreateResult {
pub workspace: WorkspaceRecord, pub workspace: WorkspaceRecord,
@@ -45,11 +31,21 @@ pub struct WorkspaceCreateResult {
#[derive(Clone)] #[derive(Clone)]
pub struct WorkspaceCatalogService { pub struct WorkspaceCatalogService {
store: Arc<dyn ControlPlaneStore>, store: Arc<dyn ControlPlaneStore>,
signing_identities: WorkspaceSigningIdentityService,
} }
impl WorkspaceCatalogService { impl WorkspaceCatalogService {
pub fn new(store: Arc<dyn ControlPlaneStore>) -> Self { pub fn new(
Self { store } store: Arc<dyn ControlPlaneStore>,
signing_materials: Arc<dyn WorkspaceSigningMaterialStore>,
) -> Self {
Self {
signing_identities: WorkspaceSigningIdentityService::new(
store.clone(),
signing_materials,
),
store,
}
} }
pub fn is_empty(&self) -> Result<bool> { pub fn is_empty(&self) -> Result<bool> {
@@ -118,7 +114,7 @@ impl WorkspaceCatalogService {
.map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string())) .map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string()))
}) })
.transpose()?; .transpose()?;
let workspace_id = requested_workspace_id let proposed_workspace_id = requested_workspace_id
.clone() .clone()
.unwrap_or_else(|| Uuid::now_v7().to_string()); .unwrap_or_else(|| Uuid::now_v7().to_string());
let fingerprint = workspace_create_fingerprint( let fingerprint = workspace_create_fingerprint(
@@ -130,9 +126,16 @@ impl WorkspaceCatalogService {
&default_ref, &default_ref,
); );
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let result = self let (signing_identity, identity_provisioning_operation_key) =
.store self.signing_identities.prepare_workspace_creation(
.create_workspace_bootstrap(&WorkspaceBootstrapRecord { &operation_key,
&fingerprint,
&proposed_workspace_id,
&owner_account_id,
)?;
let workspace_id = signing_identity.workspace_id.clone();
let result = self.store.create_workspace_bootstrap(
&WorkspaceBootstrapRecord {
operation_key, operation_key,
request_fingerprint: fingerprint.clone(), request_fingerprint: fingerprint.clone(),
workspace: WorkspaceRecord { workspace: WorkspaceRecord {
@@ -158,7 +161,10 @@ impl WorkspaceCatalogService {
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}, },
})?; },
&signing_identity,
&identity_provisioning_operation_key,
)?;
Ok(WorkspaceCreateResult { Ok(WorkspaceCreateResult {
workspace: result.workspace, workspace: result.workspace,
repository: result.repository, repository: result.repository,
@@ -214,10 +220,45 @@ fn workspace_create_fingerprint(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::*; use super::*;
use crate::store::{AccountRecord, SqliteWorkspaceStore}; use crate::store::{AccountRecord, SqliteWorkspaceStore};
use crate::workspace_signing_identity::{
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore,
WorkspaceSigningPrivateMaterial, identity_error,
};
use workspace_api::RepositorySourceKind; use workspace_api::RepositorySourceKind;
struct FailFirstMaterialWrite {
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
fail: AtomicBool,
}
impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
self.inner.load(material_ref)
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
if self.fail.swap(false, Ordering::SeqCst) {
return Err(identity_error(
"workspace_signing_identity_material_io_failed",
"injected private material write failure",
));
}
self.inner.put_if_absent(material_ref, material)
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.inner.delete(material_ref)
}
}
fn git_repository() -> tempfile::TempDir { fn git_repository() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap(); std::fs::create_dir(dir.path().join(".git")).unwrap();
@@ -243,7 +284,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn create_is_atomic_and_exact_retries_converge() { async fn create_is_atomic_and_exact_retries_converge() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let service = WorkspaceCatalogService::new(store.clone()); let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository = git_repository(); let repository = git_repository();
let request = WorkspaceCreateRequest { let request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(), operation_key: "request-1".to_string(),
@@ -268,6 +314,14 @@ mod tests {
replayed.workspace.workspace_id replayed.workspace.workspace_id
); );
assert_eq!(store.list_workspaces().unwrap().len(), 1); assert_eq!(store.list_workspaces().unwrap().len(), 1);
let signing_identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.expect("new Workspace signing identity");
assert_eq!(signing_identity.state, "active");
assert_eq!(signing_identity.algorithm, "ed25519");
assert!(signing_identity.public_key.is_some());
assert!(signing_identity.public_key_fingerprint.is_some());
assert_eq!( assert_eq!(
store store
.list_repositories(&created.workspace.workspace_id) .list_repositories(&created.workspace.workspace_id)
@@ -283,11 +337,137 @@ mod tests {
); );
} }
#[tokio::test]
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(FailFirstMaterialWrite {
inner: materials.clone(),
fail: AtomicBool::new(true),
}),
);
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "material-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
};
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let reserved_key = store
.with_conn(|conn| {
conn.query_row(
"SELECT key_id FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:material-failure' AND state = 'pending'",
[],
|row| row.get::<_, String>(0),
)
.map_err(Error::from)
})
.unwrap();
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
}
#[tokio::test]
async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(store.clone(), materials.clone());
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "db-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
};
store
.with_conn(|conn| {
conn.execute_batch(
r#"CREATE TRIGGER fail_workspace_create_identity_audit
BEFORE INSERT ON workspace_signing_identity_audit
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
)?;
Ok(())
})
.unwrap();
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let (reserved_key, material_ref) = store
.with_conn(|conn| {
conn.query_row(
"SELECT key_id, private_material_ref FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:db-failure' AND state = 'pending'",
[],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.map_err(Error::from)
})
.unwrap();
assert!(materials.load(&material_ref).unwrap().is_some());
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?;
Ok(())
})
.unwrap();
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(identity.state, "active");
}
#[tokio::test] #[tokio::test]
async fn idempotency_key_reuse_with_different_payload_is_rejected() { async fn idempotency_key_reuse_with_different_payload_is_rejected() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref()); let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store); let service = WorkspaceCatalogService::new(
store,
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository = git_repository(); let repository = git_repository();
let mut request = WorkspaceCreateRequest { let mut request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(), operation_key: "request-1".to_string(),
@@ -338,7 +518,12 @@ mod tests {
updated_at: "2026-07-03T00:00:00Z".to_string(), updated_at: "2026-07-03T00:00:00Z".to_string(),
}) })
.unwrap(); .unwrap();
let service = WorkspaceCatalogService::new(store); let service = WorkspaceCatalogService::new(
store,
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository = git_repository(); let repository = git_repository();
let error = service let error = service
.create( .create(
@@ -373,7 +558,12 @@ mod tests {
updated_at: "2026-07-03T00:00:00Z".to_string(), updated_at: "2026-07-03T00:00:00Z".to_string(),
}) })
.unwrap(); .unwrap();
let service = WorkspaceCatalogService::new(store); let service = WorkspaceCatalogService::new(
store,
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository_a = git_repository(); let repository_a = git_repository();
let repository_b = git_repository(); let repository_b = git_repository();
let created_a = service let created_a = service
@@ -425,7 +615,12 @@ mod tests {
fn remote_repository_creation_persists_typed_source_without_auth_metadata() { fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref()); let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store.clone()); let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let result = service let result = service
.create( .create(
WorkspaceCreateRequest { WorkspaceCreateRequest {
@@ -32,6 +32,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"objective_ticket_links", "objective_ticket_links",
"objectives", "objectives",
"repositories", "repositories",
"runtime_removal_operations",
"repository_secret_audit_events", "repository_secret_audit_events",
"repository_secret_operations", "repository_secret_operations",
"repository_ssh_credential_revisions", "repository_ssh_credential_revisions",
@@ -55,6 +56,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"typed_ticket_relations", "typed_ticket_relations",
"typed_ticket_risk_flags", "typed_ticket_risk_flags",
"typed_tickets", "typed_tickets",
"workdir_create_credential_candidates",
"workdir_create_credential_revision_retentions",
"workdir_create_operations", "workdir_create_operations",
"workdir_registry", "workdir_registry",
"workdir_removal_operations", "workdir_removal_operations",
@@ -80,6 +83,10 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"workspace_resource_keys", "workspace_resource_keys",
"workspace_runtime_binding_audit", "workspace_runtime_binding_audit",
"workspace_runtime_bindings", "workspace_runtime_bindings",
"workspace_runtime_verifications",
"workspace_signing_identities",
"workspace_signing_identity_audit",
"workspace_signing_identity_provisioning_operations",
"workspace_worker_retention_policies", "workspace_worker_retention_policies",
"workspace_worker_retention_policy_revisions", "workspace_worker_retention_policy_revisions",
]; ];
@@ -0,0 +1,880 @@
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use chrono::{SecondsFormat, Utc};
use ring::signature::KeyPair;
use serde::{Deserialize, Serialize};
use worker_runtime::auth::{RuntimeIdentityMaterial, encode_public_key};
use worker_runtime::workspace_issuer::{
WorkspaceCapabilityClaims, WorkspaceCapabilityVerificationError,
assemble_workspace_capability_token, workspace_capability_signing_input,
};
use zeroize::Zeroize;
use crate::store::{
ControlPlaneStore, WorkspaceSigningIdentityActivation,
WorkspaceSigningIdentityProvisioningOperation, WorkspaceSigningIdentityRecord,
};
use crate::{Error, Result};
pub const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519";
pub const WORKSPACE_SIGNING_IDENTITY_REVISION: u64 = 1;
const MATERIAL_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceSigningPrivateMaterial {
version: u32,
workspace_id: String,
key_id: String,
revision: u64,
private_key: String,
}
impl fmt::Debug for WorkspaceSigningPrivateMaterial {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("WorkspaceSigningPrivateMaterial")
.field("version", &self.version)
.field("workspace_id", &self.workspace_id)
.field("key_id", &self.key_id)
.field("revision", &self.revision)
.field("private_key", &"[REDACTED]")
.finish()
}
}
impl Drop for WorkspaceSigningPrivateMaterial {
fn drop(&mut self) {
self.private_key.zeroize();
}
}
impl WorkspaceSigningPrivateMaterial {
pub fn generate(workspace_id: &str, key_id: &str) -> Result<Self> {
let material = RuntimeIdentityMaterial::generate(key_id.to_string()).map_err(|error| {
identity_error(
"workspace_signing_identity_generation_failed",
format!("failed to generate Workspace signing identity: {error}"),
)
})?;
Ok(Self {
version: MATERIAL_SCHEMA_VERSION,
workspace_id: workspace_id.to_string(),
key_id: key_id.to_string(),
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
private_key: material.private_key,
})
}
fn signing_key(
&self,
expected_workspace_id: &str,
expected_key_id: &str,
expected_revision: u64,
) -> Result<ring::signature::Ed25519KeyPair> {
if self.version != MATERIAL_SCHEMA_VERSION
|| self.workspace_id != expected_workspace_id
|| self.key_id != expected_key_id
|| self.revision != expected_revision
{
return Err(identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing private material does not match its persisted metadata",
));
}
RuntimeIdentityMaterial {
identity_id: self.key_id.clone(),
public_key: String::new(),
private_key: self.private_key.clone(),
}
.signing_key()
.map_err(|_| {
identity_error(
"workspace_signing_identity_material_corrupt",
"Workspace signing private material is corrupt",
)
})
}
pub fn validate_and_public_key(
&self,
expected_workspace_id: &str,
expected_key_id: &str,
expected_revision: u64,
) -> Result<String> {
let signing_key =
self.signing_key(expected_workspace_id, expected_key_id, expected_revision)?;
Ok(encode_public_key(signing_key.public_key().as_ref()))
}
}
pub trait WorkspaceSigningMaterialStore: Send + Sync {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>>;
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial>;
fn delete(&self, material_ref: &str) -> Result<()>;
}
#[derive(Clone)]
pub struct WorkspaceSigningIdentityService {
store: Arc<dyn ControlPlaneStore>,
materials: Arc<dyn WorkspaceSigningMaterialStore>,
}
impl WorkspaceSigningIdentityService {
pub fn new(
store: Arc<dyn ControlPlaneStore>,
materials: Arc<dyn WorkspaceSigningMaterialStore>,
) -> Self {
Self { store, materials }
}
pub fn prepare_workspace_creation(
&self,
workspace_create_operation_key: &str,
request_fingerprint: &str,
proposed_workspace_id: &str,
actor_account_id: &str,
) -> Result<(WorkspaceSigningIdentityActivation, String)> {
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let proposed_key_id = format!("WK-{}", uuid::Uuid::now_v7().simple());
let proposed_material_ref = format!("workspace-signing/{proposed_workspace_id}/ed25519-v1");
let operation_key = format!("workspace-create:{workspace_create_operation_key}");
let operation = self.store.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: operation_key.clone(),
request_fingerprint: request_fingerprint.to_string(),
operation_kind: "workspace_create".to_string(),
workspace_id: proposed_workspace_id.to_string(),
key_id: proposed_key_id,
private_material_ref: proposed_material_ref,
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
actor_account_id: actor_account_id.to_string(),
state: "pending".to_string(),
created_at: now,
completed_at: None,
},
)?;
let activation = self.prepare_material(&operation)?;
Ok((activation, operation.operation_key))
}
pub fn provision_existing(
&self,
workspace_id: &str,
actor_account_id: &str,
) -> Result<WorkspaceSigningIdentityRecord> {
let identity = self
.store
.get_workspace_signing_identity(workspace_id)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_metadata_missing",
"Workspace signing identity metadata is missing",
)
})?;
if identity.state == "active" {
self.validate_active_material(&identity)?;
return Ok(identity);
}
if identity.state != "pending_provisioning" {
return Err(identity_error(
"workspace_signing_identity_state_invalid",
"Workspace signing identity state is invalid",
));
}
let operation_key = format!(
"existing-workspace:{workspace_id}:revision-{}",
identity.revision
);
let request_fingerprint =
provisioning_fingerprint(workspace_id, &identity.key_id, identity.revision);
let operation = self.store.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: operation_key.clone(),
request_fingerprint,
operation_kind: "existing_workspace".to_string(),
workspace_id: workspace_id.to_string(),
key_id: identity.key_id.clone(),
private_material_ref: identity.private_material_ref.clone(),
revision: identity.revision,
actor_account_id: actor_account_id.to_string(),
state: "pending".to_string(),
created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
completed_at: None,
},
)?;
let activation = self.prepare_material(&operation)?;
self.store.activate_workspace_signing_identity(
&activation,
&operation.operation_key,
&operation.actor_account_id,
)
}
pub fn get_validated(&self, workspace_id: &str) -> Result<WorkspaceSigningIdentityRecord> {
let identity = self
.store
.get_workspace_signing_identity(workspace_id)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_metadata_missing",
"Workspace signing identity metadata is missing",
)
})?;
if identity.state == "active" {
self.validate_active_material(&identity)?;
}
Ok(identity)
}
pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result<Vec<u8>> {
let identity = self.get_validated(workspace_id)?;
if identity.state != "active" {
return Err(identity_error(
"workspace_signing_identity_not_provisioned",
"Workspace signing identity is not provisioned",
));
}
let material = self
.materials
.load(&identity.private_material_ref)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_missing",
"Workspace signing private material is missing",
)
})?;
let signing_key =
material.signing_key(workspace_id, &identity.key_id, identity.revision)?;
Ok(signing_key.sign(payload).as_ref().to_vec())
}
pub fn issue_workspace_capability(
&self,
workspace_id: &str,
claims: &WorkspaceCapabilityClaims,
) -> Result<String> {
let input = workspace_capability_signing_input(claims).map_err(capability_error)?;
let signature = self.sign(workspace_id, input.bytes())?;
assemble_workspace_capability_token(input, &signature).map_err(capability_error)
}
pub fn delete_material(&self, workspace_id: &str) -> Result<()> {
if let Some(identity) = self.store.get_workspace_signing_identity(workspace_id)? {
self.materials.delete(&identity.private_material_ref)?;
}
Ok(())
}
fn prepare_material(
&self,
operation: &WorkspaceSigningIdentityProvisioningOperation,
) -> Result<WorkspaceSigningIdentityActivation> {
let material = match self.materials.load(&operation.private_material_ref)? {
Some(material) => material,
None => {
let generated = WorkspaceSigningPrivateMaterial::generate(
&operation.workspace_id,
&operation.key_id,
)?;
self.materials
.put_if_absent(&operation.private_material_ref, &generated)?
}
};
let public_key = material.validate_and_public_key(
&operation.workspace_id,
&operation.key_id,
operation.revision,
)?;
let public_key_fingerprint = public_key_fingerprint(&public_key)?;
Ok(WorkspaceSigningIdentityActivation {
workspace_id: operation.workspace_id.clone(),
key_id: operation.key_id.clone(),
public_key,
public_key_fingerprint,
private_material_ref: operation.private_material_ref.clone(),
revision: operation.revision,
provisioned_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
})
}
fn validate_active_material(&self, identity: &WorkspaceSigningIdentityRecord) -> Result<()> {
let material = self
.materials
.load(&identity.private_material_ref)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_missing",
"Workspace signing private material is missing",
)
})?;
let public_key = material.validate_and_public_key(
&identity.workspace_id,
&identity.key_id,
identity.revision,
)?;
let fingerprint = public_key_fingerprint(&public_key)?;
if identity.public_key.as_deref() != Some(public_key.as_str())
|| identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str())
{
return Err(identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing private material does not match public metadata",
));
}
Ok(())
}
}
fn provisioning_fingerprint(workspace_id: &str, key_id: &str, revision: u64) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(workspace_id.as_bytes());
hasher.update([0]);
hasher.update(key_id.as_bytes());
hasher.update([0]);
hasher.update(revision.to_be_bytes());
format!("sha256:{}", hex_lower(&hasher.finalize()))
}
#[derive(Default)]
pub struct InMemoryWorkspaceSigningMaterialStore {
materials: std::sync::Mutex<std::collections::HashMap<String, WorkspaceSigningPrivateMaterial>>,
}
impl WorkspaceSigningMaterialStore for InMemoryWorkspaceSigningMaterialStore {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
Ok(self
.materials
.lock()
.expect("identity material store lock")
.get(material_ref)
.cloned())
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
let mut materials = self.materials.lock().expect("identity material store lock");
Ok(materials
.entry(material_ref.to_string())
.or_insert_with(|| material.clone())
.clone())
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.materials
.lock()
.expect("identity material store lock")
.remove(material_ref);
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct FsWorkspaceSigningMaterialStore {
root: PathBuf,
}
impl FsWorkspaceSigningMaterialStore {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
fn material_path(&self, material_ref: &str) -> Result<PathBuf> {
let relative = Path::new(material_ref);
if relative.as_os_str().is_empty()
|| relative.is_absolute()
|| relative.components().any(|component| {
!matches!(component, Component::Normal(_))
|| component.as_os_str().to_string_lossy().starts_with('.')
})
{
return Err(identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material reference is invalid",
));
}
Ok(self.root.join(relative).with_extension("json"))
}
}
impl WorkspaceSigningMaterialStore for FsWorkspaceSigningMaterialStore {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
let path = self.material_path(material_ref)?;
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(material_io_error("read", error)),
};
serde_json::from_slice(&bytes).map(Some).map_err(|_| {
identity_error(
"workspace_signing_identity_material_corrupt",
"Workspace signing private material is corrupt",
)
})
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
let path = self.material_path(material_ref)?;
let parent = path.parent().ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material reference has no parent",
)
})?;
ensure_private_tree(&self.root, parent)?;
let mut bytes = serde_json::to_vec(material).map_err(|_| {
identity_error(
"workspace_signing_identity_material_encode_failed",
"Workspace signing private material could not be encoded",
)
})?;
let temporary = parent.join(format!(
".workspace-signing-{}.tmp",
uuid::Uuid::now_v7().simple()
));
let write_result = (|| -> Result<()> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&temporary)
.map_err(|error| material_io_error("create", error))?;
file.write_all(&bytes)
.and_then(|()| file.sync_all())
.map_err(|error| material_io_error("write", error))?;
match fs::hard_link(&temporary, &path) {
Ok(()) => sync_directory(parent),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(material_io_error("publish", error)),
}
})();
bytes.zeroize();
let cleanup_result = match fs::remove_file(&temporary) {
Ok(()) => sync_directory(parent),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(material_io_error("remove temporary", error)),
};
write_result?;
cleanup_result?;
self.load(material_ref)?.ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_missing",
"Workspace signing private material is missing after publication",
)
})
}
fn delete(&self, material_ref: &str) -> Result<()> {
let path = self.material_path(material_ref)?;
match fs::remove_file(&path) {
Ok(()) => {
let parent = path.parent().ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material reference has no parent",
)
})?;
sync_directory(parent)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(material_io_error("delete", error)),
}
}
}
fn ensure_private_tree(root: &Path, leaf: &Path) -> Result<()> {
ensure_private_directory(root)?;
if let Some(parent) = root.parent() {
sync_directory(parent)?;
}
let relative = leaf.strip_prefix(root).map_err(|_| {
identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material path escapes its authority root",
)
})?;
let mut current = root.to_path_buf();
for component in relative.components() {
let parent = current.clone();
current.push(component);
ensure_private_directory(&current)?;
sync_directory(&parent)?;
}
Ok(())
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> Result<()> {
std::fs::File::open(path)
.and_then(|directory| directory.sync_all())
.map_err(|error| material_io_error("synchronize directory", error))
}
#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> Result<()> {
Err(identity_error(
"workspace_signing_identity_durable_publish_unsupported",
"Workspace signing private material durable publication is unsupported on this platform",
))
}
fn ensure_private_directory(path: &Path) -> Result<()> {
fs::create_dir_all(path).map_err(|error| material_io_error("create directory", error))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
.map_err(|error| material_io_error("set directory permissions", error))?;
}
Ok(())
}
pub fn workspace_signing_material_root(database_path: &Path) -> PathBuf {
database_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("workspace-signing-identities")
}
pub fn public_key_fingerprint(public_key: &str) -> Result<String> {
let bytes = worker_runtime::auth::decode_public_key(public_key).map_err(|_| {
identity_error(
"workspace_signing_identity_public_key_invalid",
"Workspace signing public key is invalid",
)
})?;
use sha2::{Digest, Sha256};
Ok(format!("sha256:{}", hex_lower(&Sha256::digest(bytes))))
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
output
}
fn material_io_error(action: &str, error: std::io::Error) -> Error {
identity_error(
"workspace_signing_identity_material_io_failed",
format!("failed to {action} Workspace signing private material: {error}"),
)
}
fn capability_error(error: WorkspaceCapabilityVerificationError) -> Error {
identity_error(
"workspace_capability_issuance_failed",
format!("failed to issue Workspace capability: {error}"),
)
}
pub fn identity_error(code: impl Into<String>, message: impl Into<String>) -> Error {
Error::WorkspaceSigningIdentity {
code: code.into(),
message: message.into(),
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::*;
struct FailFirstMaterialWrite {
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
fail: AtomicBool,
}
impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
self.inner.load(material_ref)
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
if self.fail.swap(false, Ordering::SeqCst) {
return Err(identity_error(
"workspace_signing_identity_material_io_failed",
"injected private material write failure",
));
}
self.inner.put_if_absent(material_ref, material)
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.inner.delete(material_ref)
}
}
#[tokio::test]
async fn existing_workspace_provisioning_is_audited_idempotent_and_fails_closed_when_missing() {
use crate::store::{AccountRecord, SqliteWorkspaceStore, WorkspaceRecord};
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
store
.upsert_account(&AccountRecord {
account_id: "account-1".to_string(),
kind: "user".to_string(),
handle: "owner".to_string(),
display_name: "Owner".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-1".to_string(),
owner_account_id: "account-1".to_string(),
display_name: "Workspace".to_string(),
state: "active".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.await
.unwrap();
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let failing_service = WorkspaceSigningIdentityService::new(
store.clone(),
Arc::new(FailFirstMaterialWrite {
inner: materials.clone(),
fail: AtomicBool::new(true),
}),
);
assert_eq!(
failing_service.get_validated("workspace-1").unwrap().state,
"pending_provisioning"
);
let error = failing_service
.provision_existing("workspace-1", "account-1")
.unwrap_err();
assert!(matches!(
error,
Error::WorkspaceSigningIdentity { ref code, .. }
if code == "workspace_signing_identity_material_io_failed"
));
assert_eq!(
store
.get_workspace_signing_identity("workspace-1")
.unwrap()
.unwrap()
.state,
"pending_provisioning"
);
drop(failing_service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
let provisioned = service
.provision_existing("workspace-1", "account-1")
.unwrap();
assert_eq!(provisioned.state, "active");
assert!(provisioned.public_key.is_some());
let payload = b"Workspace authority proof";
let signature = service.sign("workspace-1", payload).unwrap();
let public_key =
worker_runtime::auth::decode_public_key(provisioned.public_key.as_deref().unwrap())
.unwrap();
ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key)
.verify(payload, &signature)
.unwrap();
assert_eq!(
service
.provision_existing("workspace-1", "account-1")
.unwrap(),
provisioned
);
store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM workspace_signing_identity_audit WHERE workspace_id = 'workspace-1'",
[],
|row| row.get::<_, i64>(0),
)?,
1
);
Ok(())
})
.unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-2".to_string(),
owner_account_id: "account-1".to_string(),
display_name: "Workspace 2".to_string(),
state: "active".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.await
.unwrap();
let pending = store
.get_workspace_signing_identity("workspace-2")
.unwrap()
.unwrap();
let operation = store
.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: "existing-workspace:workspace-2:revision-1".to_string(),
request_fingerprint: provisioning_fingerprint(
"workspace-2",
&pending.key_id,
pending.revision,
),
operation_kind: "existing_workspace".to_string(),
workspace_id: "workspace-2".to_string(),
key_id: pending.key_id.clone(),
private_material_ref: pending.private_material_ref.clone(),
revision: pending.revision,
actor_account_id: "account-1".to_string(),
state: "pending".to_string(),
created_at: "1".to_string(),
completed_at: None,
},
)
.unwrap();
let activation = service.prepare_material(&operation).unwrap();
store
.with_conn(|conn| {
conn.execute_batch(
r#"CREATE TRIGGER fail_workspace_signing_identity_audit
BEFORE INSERT ON workspace_signing_identity_audit
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
)?;
Ok(())
})
.unwrap();
assert!(
store
.activate_workspace_signing_identity(
&activation,
&operation.operation_key,
"account-1",
)
.is_err()
);
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER fail_workspace_signing_identity_audit;")?;
Ok(())
})
.unwrap();
assert_eq!(
store
.get_workspace_signing_identity("workspace-2")
.unwrap()
.unwrap()
.state,
"pending_provisioning"
);
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
let recovered = restarted
.provision_existing("workspace-2", "account-1")
.unwrap();
assert_eq!(recovered.key_id, activation.key_id);
assert_eq!(
recovered.public_key_fingerprint.as_deref(),
Some(activation.public_key_fingerprint.as_str())
);
materials.delete(&provisioned.private_material_ref).unwrap();
let error = restarted.get_validated("workspace-1").unwrap_err();
assert!(matches!(
error,
Error::WorkspaceSigningIdentity { ref code, .. }
if code == "workspace_signing_identity_material_missing"
));
}
#[test]
fn file_store_round_trips_private_material_without_overwrite() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities"));
let first = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
let first_public = first.validate_and_public_key("ws-1", "WK-1", 1).unwrap();
let persisted = store.put_if_absent("ws-1/ed25519-v1", &first).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(temp.path().join("identities"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
fs::metadata(store.material_path("ws-1/ed25519-v1").unwrap())
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
assert_eq!(
persisted
.validate_and_public_key("ws-1", "WK-1", 1)
.unwrap(),
first_public
);
let second = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
let persisted = store.put_if_absent("ws-1/ed25519-v1", &second).unwrap();
assert_eq!(
persisted
.validate_and_public_key("ws-1", "WK-1", 1)
.unwrap(),
first_public
);
}
#[test]
fn corrupt_and_cross_workspace_material_fail_closed() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities"));
let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
store.put_if_absent("ws-1/ed25519-v1", &material).unwrap();
let loaded = store.load("ws-1/ed25519-v1").unwrap().unwrap();
assert!(loaded.validate_and_public_key("ws-2", "WK-1", 1).is_err());
fs::write(store.material_path("ws-1/ed25519-v1").unwrap(), b"not json").unwrap();
assert!(store.load("ws-1/ed25519-v1").is_err());
}
}
+1
View File
@@ -25,6 +25,7 @@ serde_json = { workspace = true }
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
toml = { workspace = true } toml = { workspace = true }
uuid = { workspace = true }
[dev-dependencies] [dev-dependencies]
tempfile = { workspace = true } tempfile = { workspace = true }
+23
View File
@@ -1,3 +1,5 @@
use std::path::Path;
use client::{BackendTarget, StandaloneTarget, Target, TargetKind}; use client::{BackendTarget, StandaloneTarget, Target, TargetKind};
use serde::Deserialize; use serde::Deserialize;
@@ -103,6 +105,16 @@ pub(crate) trait CliConnectionResolver {
command: CliCommand, command: CliCommand,
input: CliConnectionInput<'_>, input: CliConnectionInput<'_>,
) -> Result<Box<dyn Target>, ParseError>; ) -> Result<Box<dyn Target>, ParseError>;
fn select_workspace_for_repository(
&self,
_explicit_backend_url: Option<&str>,
_repository_path: &Path,
) -> Result<(String, String), ParseError> {
Err(ParseError(
"repository-based Workspace selection is unavailable".to_string(),
))
}
} }
#[derive(Debug, Default, Clone, Copy)] #[derive(Debug, Default, Clone, Copy)]
@@ -177,6 +189,17 @@ impl CliConnectionResolver for ClientConfigCliConnectionResolver {
}, },
} }
} }
fn select_workspace_for_repository(
&self,
explicit_backend_url: Option<&str>,
repository_path: &Path,
) -> Result<(String, String), ParseError> {
let base_url = resolve_backend_url(explicit_backend_url.map(str::to_string), None)?;
let workspace_id =
super::select_backend_workspace_for_repository(&base_url, repository_path)?;
Ok((base_url, workspace_id))
}
} }
pub(crate) fn resolve_local_cli_connection<R: CliConnectionResolver + ?Sized>( pub(crate) fn resolve_local_cli_connection<R: CliConnectionResolver + ?Sized>(
+305 -134
View File
@@ -6,6 +6,7 @@ mod plugin_cli;
mod session_cli; mod session_cli;
mod ticket_cli; mod ticket_cli;
mod worker_cleanup_cli; mod worker_cleanup_cli;
mod workspace_bootstrap;
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt; use std::fmt;
@@ -24,6 +25,9 @@ use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for
use memory_lint::{LintCliOptions, LintStatus}; use memory_lint::{LintCliOptions, LintStatus};
use serde::Deserialize; use serde::Deserialize;
use tui::{LaunchMode, LaunchOptions}; use tui::{LaunchMode, LaunchOptions};
use workspace_bootstrap::{
InitOptions, discover_repository_root, run_init, select_backend_workspace_for_repository,
};
#[derive(Debug)] #[derive(Debug)]
enum Mode { enum Mode {
@@ -48,6 +52,7 @@ enum Mode {
backend_url: String, backend_url: String,
no_wait: bool, no_wait: bool,
}, },
Init(InitOptions),
WorkerRuntime(Vec<String>), WorkerRuntime(Vec<String>),
Keys, Keys,
SetupModel, SetupModel,
@@ -69,9 +74,12 @@ impl fmt::Display for ParseError {
impl std::error::Error for ParseError {} impl std::error::Error for ParseError {}
#[tokio::main] fn main() -> ExitCode {
async fn main() -> ExitCode { start_cli(parse_args)
let mode = match parse_args() { }
fn start_cli(parse: impl FnOnce() -> Result<Mode, ParseError>) -> ExitCode {
let mode = match parse() {
Ok(mode) => mode, Ok(mode) => mode,
Err(e) => { Err(e) => {
eprintln!("yoi: {e}"); eprintln!("yoi: {e}");
@@ -80,6 +88,11 @@ async fn main() -> ExitCode {
} }
}; };
run(mode)
}
#[tokio::main]
async fn run(mode: Mode) -> ExitCode {
match mode { match mode {
Mode::Help => { Mode::Help => {
print_help(); print_help();
@@ -107,6 +120,19 @@ async fn main() -> ExitCode {
ExitCode::FAILURE ExitCode::FAILURE
} }
}, },
Mode::Init(options) => match run_init(options).await {
Ok(workspace) => {
println!(
"Initialized Workspace '{}' for repository '{}'",
workspace.workspace.display_name, workspace.repository.repository_key
);
ExitCode::SUCCESS
}
Err(error) => {
eprintln!("yoi init: {error}");
ExitCode::FAILURE
}
},
Mode::MemoryLint(options) => match memory_lint::run(&options) { Mode::MemoryLint(options) => match memory_lint::run(&options) {
Ok(LintStatus::Clean) => ExitCode::SUCCESS, Ok(LintStatus::Clean) => ExitCode::SUCCESS,
Ok(LintStatus::Failed) => ExitCode::FAILURE, Ok(LintStatus::Failed) => ExitCode::FAILURE,
@@ -232,24 +258,37 @@ fn resolve_tui_target<R: CliConnectionResolver + ?Sized>(
); );
} }
if selection.backend_url.is_none() if selection.workspace_id.is_none() {
&& let Ok(target) = if selection.backend_url.is_none()
resolve_connection_aware_cli_connection(connection_resolver, command, false, None, None) && let Ok(target) = resolve_connection_aware_cli_connection(
&& target.kind() == TargetKind::Standalone connection_resolver,
{ command,
return Ok(target); false,
None,
None,
)
&& target.kind() == TargetKind::Standalone
{
return Ok(target);
}
let (base_url, workspace_id) = connection_resolver
.select_workspace_for_repository(selection.backend_url.as_deref(), workspace_root)?;
return resolve_connection_aware_cli_connection(
connection_resolver,
command,
false,
Some(base_url),
Some(&workspace_id),
);
} }
let workspace_id = match selection.workspace_id.clone() {
Some(workspace_id) => Some(workspace_id),
None => resolve_workspace_id_from_root(workspace_root)?,
};
resolve_connection_aware_cli_connection( resolve_connection_aware_cli_connection(
connection_resolver, connection_resolver,
command, command,
selection.explicit_local, selection.explicit_local,
selection.backend_url.clone(), selection.backend_url.clone(),
workspace_id.as_deref(), selection.workspace_id.as_deref(),
) )
} }
@@ -439,6 +478,22 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
.map_err(|error| ParseError(error.to_string()))?; .map_err(|error| ParseError(error.to_string()))?;
return Ok(Mode::Ticket { cli, target }); return Ok(Mode::Ticket { cli, target });
} }
"init" => {
if target_selection.explicit_local {
return Err(ParseError(
"yoi init requires a Backend target and cannot use --local".to_string(),
));
}
if target_selection.workspace_id.is_some() {
return Err(ParseError(
"yoi init creates a Workspace and does not accept --workspace-id".to_string(),
));
}
return Ok(Mode::Init(parse_init_args(
&args[1..],
target_selection.backend_url,
)?));
}
"plugin" => { "plugin" => {
let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?; let _target = resolve_local_cli_connection(connection_resolver, CliCommand::Plugin)?;
let plugin_cli = parse_plugin_args(&args[1..])?; let plugin_cli = parse_plugin_args(&args[1..])?;
@@ -556,6 +611,68 @@ fn parse_args_slice_with_connection_resolver<R: CliConnectionResolver + ?Sized>(
parse_console_options(args, &target_selection, connection_resolver) parse_console_options(args, &target_selection, connection_resolver)
} }
fn parse_init_args(
args: &[String],
explicit_backend_url: Option<String>,
) -> Result<InitOptions, ParseError> {
let mut display_name = None;
let mut repository_key = None;
let mut repository_root = current_dir()?;
let mut default_ref = None;
let mut index = 0;
while index < args.len() {
let option = args[index].as_str();
let (slot, label) = match option {
"--display-name" => (&mut display_name, "--display-name"),
"--repository-key" => (&mut repository_key, "--repository-key"),
"--repository" => {
let value = required_option_value(args, index, "--repository")?;
repository_root = PathBuf::from(value);
index += 2;
continue;
}
"--default-ref" => (&mut default_ref, "--default-ref"),
"--help" | "-h" => {
return Err(ParseError(
"usage: yoi [--backend URL] init --display-name NAME --repository-key KEY [--repository PATH] [--default-ref REF]"
.to_string(),
));
}
unknown => {
return Err(ParseError(format!("unknown yoi init option `{unknown}`")));
}
};
if slot.is_some() {
return Err(ParseError(format!("{label} may only be provided once")));
}
*slot = Some(required_option_value(args, index, label)?.to_string());
index += 2;
}
let repository_root = discover_repository_root(&repository_root)?;
Ok(InitOptions {
backend_url: resolve_backend_url(explicit_backend_url, None)?,
display_name: display_name
.ok_or_else(|| ParseError("yoi init requires --display-name NAME".to_string()))?,
repository_key: repository_key
.ok_or_else(|| ParseError("yoi init requires --repository-key KEY".to_string()))?,
repository_root,
default_ref,
})
}
fn required_option_value<'a>(
args: &'a [String],
index: usize,
option: &str,
) -> Result<&'a str, ParseError> {
let value = args
.get(index + 1)
.filter(|value| !value.is_empty() && !value.starts_with('-'))
.ok_or_else(|| ParseError(format!("{option} requires a value")))?;
Ok(value)
}
fn parse_console_options<R: CliConnectionResolver + ?Sized>( fn parse_console_options<R: CliConnectionResolver + ?Sized>(
args: &[String], args: &[String],
target_selection: &TargetSelection, target_selection: &TargetSelection,
@@ -1050,13 +1167,7 @@ fn current_dir() -> Result<PathBuf, ParseError> {
.map_err(|e| ParseError(format!("failed to resolve current directory: {e}"))) .map_err(|e| ParseError(format!("failed to resolve current directory: {e}")))
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Default, PartialEq, Eq)]
struct WorkspaceIdentityFile {
#[serde(alias = "workspace_id")]
id: String,
}
#[derive(Debug, Default)]
struct ClientConfigFile { struct ClientConfigFile {
default_backend: Option<String>, default_backend: Option<String>,
default_connection: ClientDefaultConnection, default_connection: ClientDefaultConnection,
@@ -1074,7 +1185,7 @@ struct ClientConfigOverlay {
workspaces: BTreeMap<String, ClientWorkspaceConfigOverlay>, workspaces: BTreeMap<String, ClientWorkspaceConfigOverlay>,
} }
#[derive(Debug, Default)] #[derive(Debug, Default, PartialEq, Eq)]
struct ClientBackendConfig { struct ClientBackendConfig {
url: Option<String>, url: Option<String>,
} }
@@ -1084,7 +1195,7 @@ struct ClientBackendConfigOverlay {
url: Option<String>, url: Option<String>,
} }
#[derive(Debug, Default)] #[derive(Debug, Default, PartialEq, Eq)]
struct ClientWorkspaceConfig { struct ClientWorkspaceConfig {
backend: Option<String>, backend: Option<String>,
} }
@@ -1117,34 +1228,6 @@ impl ClientConfigFile {
} }
} }
fn resolve_workspace_id_from_root(workspace_root: &Path) -> Result<Option<String>, ParseError> {
let mut current = if workspace_root.is_absolute() {
workspace_root.to_path_buf()
} else {
current_dir()?.join(workspace_root)
};
loop {
let path = current.join(".yoi").join("workspace.toml");
if path.is_file() {
let contents = fs::read_to_string(&path)
.map_err(|e| ParseError(format!("failed to read {}: {e}", path.display())))?;
let identity: WorkspaceIdentityFile = toml::from_str(&contents)
.map_err(|e| ParseError(format!("failed to parse {}: {e}", path.display())))?;
let id = identity.id.trim();
if id.is_empty() {
return Err(ParseError(format!(
"{} must contain a non-empty workspace id",
path.display()
)));
}
return Ok(Some(id.to_string()));
}
if !current.pop() {
return Ok(None);
}
}
}
fn resolve_backend_url( fn resolve_backend_url(
explicit_backend_url: Option<String>, explicit_backend_url: Option<String>,
workspace_id: Option<&str>, workspace_id: Option<&str>,
@@ -1219,13 +1302,13 @@ fn read_client_config_overlay(path: &Path) -> Result<Option<ClientConfigOverlay>
} }
fn client_global_config_path() -> Option<PathBuf> { fn client_global_config_path() -> Option<PathBuf> {
manifest::paths::data_dir().map(|dir| dir.join("client").join("config.toml")) manifest::paths::config_dir().map(|dir| dir.join("client.toml"))
} }
fn client_config_location_message() -> String { fn client_config_location_message() -> String {
match client_global_config_path() { match client_global_config_path() {
Some(path) => path.display().to_string(), Some(path) => path.display().to_string(),
None => "<data_dir>/client/config.toml".to_string(), None => "<config_dir>/client.toml".to_string(),
} }
} }
@@ -1345,7 +1428,7 @@ async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> {
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> { fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
let Some((subcommand, rest)) = args.split_first() else { let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError( return Err(ParseError(
"yoi plugin requires `new`, `check`, `pack`, `list`, or `show <ref>`".to_string(), "yoi plugin requires `new`, `check`, or `pack`".to_string(),
)); ));
}; };
match subcommand.as_str() { match subcommand.as_str() {
@@ -1397,30 +1480,6 @@ fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, Pa
)), )),
} }
} }
"list" => {
let (plugin_args, positional) = parse_plugin_common_args(rest)?;
if !positional.is_empty() {
return Err(ParseError(
"yoi plugin list does not accept positional arguments".to_string(),
));
}
Ok(plugin_cli::PluginCliCommand::List(plugin_args))
}
"show" => {
let (plugin_args, positional) = parse_plugin_common_args(rest)?;
match positional.as_slice() {
[reference] => Ok(plugin_cli::PluginCliCommand::Show {
reference: reference.clone(),
args: plugin_args,
}),
[] => Err(ParseError(
"yoi plugin show requires a plugin ref".to_string(),
)),
_ => Err(ParseError(
"yoi plugin show accepts exactly one plugin ref".to_string(),
)),
}
}
"--help" | "-h" => Err(ParseError(plugin_usage().to_string())), "--help" | "-h" => Err(ParseError(plugin_usage().to_string())),
other => Err(ParseError(format!( other => Err(ParseError(format!(
"unknown yoi plugin subcommand `{other}`" "unknown yoi plugin subcommand `{other}`"
@@ -1438,35 +1497,7 @@ fn parse_plugin_common_args(
let arg = &args[index]; let arg = &args[index];
match arg.as_str() { match arg.as_str() {
"--json" => parsed.json = true, "--json" => parsed.json = true,
"--workspace" => {
index += 1;
let Some(value) = args.get(index) else {
return Err(ParseError("--workspace requires a value".to_string()));
};
parsed.workspace = Some(PathBuf::from(value));
}
"--profile" => {
index += 1;
let Some(value) = args.get(index) else {
return Err(ParseError("--profile requires a value".to_string()));
};
parsed.profile = Some(value.clone());
}
"--help" | "-h" => return Err(ParseError(plugin_usage().to_string())), "--help" | "-h" => return Err(ParseError(plugin_usage().to_string())),
_ if arg.starts_with("--workspace=") => {
let value = arg.trim_start_matches("--workspace=");
if value.is_empty() {
return Err(ParseError("--workspace requires a value".to_string()));
}
parsed.workspace = Some(PathBuf::from(value));
}
_ if arg.starts_with("--profile=") => {
let value = arg.trim_start_matches("--profile=");
if value.is_empty() {
return Err(ParseError("--profile requires a value".to_string()));
}
parsed.profile = Some(value.to_string());
}
_ if arg.starts_with('-') => { _ if arg.starts_with('-') => {
return Err(ParseError(format!("unknown yoi plugin option `{arg}`"))); return Err(ParseError(format!("unknown yoi plugin option `{arg}`")));
} }
@@ -1506,7 +1537,7 @@ fn parse_plugin_pack_args(
} }
fn plugin_usage() -> &'static str { fn plugin_usage() -> &'static str {
"usage: yoi plugin new <rust-component-tool|rust-component-service> <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]\n yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show <ref> [--workspace PATH] [--profile REF] [--json]" "usage: yoi plugin new <rust-component-tool|rust-component-service> <path-or-name> [--json]\n yoi plugin check <path-or-package> [--json]\n yoi plugin pack <path> [--output <file>] [--json]"
} }
fn parse_mcp_args(args: &[String]) -> Result<mcp_cli::McpCliCommand, ParseError> { fn parse_mcp_args(args: &[String]) -> Result<mcp_cli::McpCliCommand, ParseError> {
@@ -1701,6 +1732,7 @@ Usage:
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>] yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
yoi [TARGET] resume [--all] [--runtime-id <ID>] yoi [TARGET] resume [--all] [--runtime-id <ID>]
yoi --backend <URL> [--workspace-id <ID>] panel yoi --backend <URL> [--workspace-id <ID>] panel
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
yoi [--backend <URL>] login [--no-wait] yoi [--backend <URL>] login [--no-wait]
yoi <HOST_COMMAND> [OPTIONS] yoi <HOST_COMMAND> [OPTIONS]
@@ -1726,12 +1758,13 @@ Console options:
--worker-id <ID> Backend Worker id; requires --runtime-id --worker-id <ID> Backend Worker id; requires --runtime-id
Host commands: Host commands:
yoi init Register the current Git repository as a new Backend Workspace.
keys Manage local model/API keys keys Manage local model/API keys
setup-model Configure a local model provider setup-model Configure a local model provider
worker [WORKER_OPTIONS] Run the direct Worker process entrypoint worker [WORKER_OPTIONS] Run the direct Worker process entrypoint
ticket <COMMAND> Manage Tickets through a Backend target ticket <COMMAND> Manage Tickets through a Backend target
objective <COMMAND> Manage Objectives through a Backend target objective <COMMAND> Manage Objectives through a Backend target
plugin <COMMAND> Build/check/list/show plugins plugin <COMMAND> Author/check/pack explicit Plugin packages
mcp <COMMAND> Inspect configured MCP servers mcp <COMMAND> Inspect configured MCP servers
memory lint Lint local memory files memory lint Lint local memory files
session <COMMAND> Inspect/prune Standalone session logs session <COMMAND> Inspect/prune Standalone session logs
@@ -1760,7 +1793,7 @@ Authority:
Options: Options:
--backend <URL> Use this Workspace Backend --backend <URL> Use this Workspace Backend
--workspace-id <ID> Scope Backend routes to a Workspace id --workspace-id <ID> Scope Backend routes to a Workspace id
--workspace <PATH> Resolve Backend Workspace identity from this repository root --workspace <PATH> Match this Git repository against Server DB Repository records
-r, --stopped List stopped Backend Workers -r, --stopped List stopped Backend Workers
--runtime-id <ID> Restrict the Backend Worker picker to a Runtime id --runtime-id <ID> Restrict the Backend Worker picker to a Runtime id
-h, --help Print help -h, --help Print help
@@ -1800,8 +1833,25 @@ fn print_memory_lint_help() {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn cli_parses_connection_before_starting_tokio_runtime() {
let mut parsed = false;
let _ = start_cli(|| {
assert!(
tokio::runtime::Handle::try_current().is_err(),
"connection resolution must run before the CLI Tokio runtime starts"
);
parsed = true;
Ok(Mode::Help)
});
assert!(parsed);
}
use crate::cli_connection::CliConnectionInput; use crate::cli_connection::CliConnectionInput;
use client::{BackendTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest}; use client::{BackendTarget, StandaloneTarget, Target, TargetKind, WorkerListRequest};
use std::process::Command;
struct FixedCliConnectionResolver { struct FixedCliConnectionResolver {
backend_url: &'static str, backend_url: &'static str,
@@ -1850,6 +1900,42 @@ mod tests {
workspace_id.map(str::to_string), workspace_id.map(str::to_string),
))) )))
} }
fn select_workspace_for_repository(
&self,
_explicit_backend_url: Option<&str>,
_repository_path: &Path,
) -> Result<(String, String), ParseError> {
Ok((
self.backend_url.to_string(),
"workspace-from-backend".to_string(),
))
}
}
struct OfflineBackendCliConnectionResolver;
impl CliConnectionResolver for OfflineBackendCliConnectionResolver {
fn resolve_connection(
&self,
_command: CliCommand,
_input: cli_connection::CliConnectionInput<'_>,
) -> Result<Box<dyn Target>, ParseError> {
Ok(Box::new(BackendTarget::new(
"http://offline.example",
None::<String>,
)))
}
fn select_workspace_for_repository(
&self,
_explicit_backend_url: Option<&str>,
_repository_path: &Path,
) -> Result<(String, String), ParseError> {
Err(ParseError(
"failed to query Backend Workspace catalog: Backend is offline".to_string(),
))
}
} }
#[test] #[test]
@@ -1951,6 +2037,12 @@ backend = "shared"
assert!(TOP_LEVEL_HELP.contains("default_connection = \"local\"")); assert!(TOP_LEVEL_HELP.contains("default_connection = \"local\""));
assert!(TOP_LEVEL_HELP.contains("yoi-server")); assert!(TOP_LEVEL_HELP.contains("yoi-server"));
assert!(TOP_LEVEL_HELP.contains("yoi-runtime")); assert!(TOP_LEVEL_HELP.contains("yoi-runtime"));
assert!(
TOP_LEVEL_HELP.contains(
"plugin <COMMAND> Author/check/pack explicit Plugin packages"
)
);
assert!(!TOP_LEVEL_HELP.contains("Build/check/list/show plugins"));
assert!(!TOP_LEVEL_HELP.contains("yoi workspace")); assert!(!TOP_LEVEL_HELP.contains("yoi workspace"));
assert!(!TOP_LEVEL_HELP.contains("yoi server")); assert!(!TOP_LEVEL_HELP.contains("yoi server"));
assert!(!TOP_LEVEL_HELP.contains("TARGET_OPTIONS")); assert!(!TOP_LEVEL_HELP.contains("TARGET_OPTIONS"));
@@ -2028,6 +2120,8 @@ backend = "shared"
match parse_args_from([ match parse_args_from([
"--backend", "--backend",
"http://127.0.0.1:8787", "http://127.0.0.1:8787",
"--workspace-id",
"workspace-a",
"--runtime-id", "--runtime-id",
"runtime-a", "runtime-a",
"--worker-id", "--worker-id",
@@ -2090,7 +2184,15 @@ backend = "shared"
#[test] #[test]
fn parse_backend_runtime_picker_target_mode() { fn parse_backend_runtime_picker_target_mode() {
match parse_args_from(["--backend", "http://127.0.0.1:8787", "--runtime-id", "r"]).unwrap() match parse_args_from([
"--backend",
"http://127.0.0.1:8787",
"--workspace-id",
"workspace-a",
"--runtime-id",
"r",
])
.unwrap()
{ {
Mode::Tui { Mode::Tui {
target, target,
@@ -2447,7 +2549,71 @@ backend = "shared"
} }
#[test] #[test]
fn default_backend_target_inherits_workspace_identity_from_workspace_root() { fn backend_workspace_selection_reports_offline_without_local_fallback() {
let workspace = tempfile::tempdir().unwrap();
fs::create_dir_all(workspace.path().join(".yoi")).unwrap();
fs::write(
workspace.path().join(".yoi/workspace.toml"),
"workspace_id = \"stale-local-workspace\"\n",
)
.unwrap();
let error = resolve_tui_target(
&OfflineBackendCliConnectionResolver,
CliCommand::Ticket,
&TargetSelection::default(),
workspace.path(),
)
.unwrap_err()
.to_string();
assert!(error.contains("Backend is offline"));
assert!(!error.contains("stale-local-workspace"));
}
#[test]
fn init_parsing_uses_git_root_without_writing_repository_local_identity() {
let repository = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init", "-q"])
.current_dir(repository.path())
.status()
.unwrap();
let resolver = FixedCliConnectionResolver {
backend_url: "http://unused.example",
};
let args = vec![
"--backend".to_string(),
"http://backend.example".to_string(),
"init".to_string(),
"--display-name".to_string(),
"Workspace A".to_string(),
"--repository-key".to_string(),
"main".to_string(),
"--repository".to_string(),
repository.path().display().to_string(),
"--default-ref".to_string(),
"develop".to_string(),
];
let mode = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap();
let Mode::Init(options) = mode else {
panic!("expected init mode");
};
assert_eq!(options.backend_url, "http://backend.example");
assert_eq!(options.display_name, "Workspace A");
assert_eq!(options.repository_key, "main");
assert_eq!(options.default_ref.as_deref(), Some("develop"));
assert_eq!(
options.repository_root,
fs::canonicalize(repository.path()).unwrap()
);
assert!(!repository.path().join(".yoi/workspace.toml").exists());
}
#[test]
fn default_backend_target_selects_workspace_from_backend_not_repository_file() {
let workspace = tempfile::tempdir().unwrap(); let workspace = tempfile::tempdir().unwrap();
std::fs::create_dir_all(workspace.path().join(".yoi")).unwrap(); std::fs::create_dir_all(workspace.path().join(".yoi")).unwrap();
std::fs::write( std::fs::write(
@@ -2467,11 +2633,12 @@ backend = "shared"
) )
.unwrap(); .unwrap();
assert_eq!(target.kind(), TargetKind::Backend);
assert_eq!( assert_eq!(
target.resolve().unwrap(), target.resolve().unwrap(),
client::ResolvedTarget::Backend { client::ResolvedTarget::Backend {
base_url: "http://default-backend.example".to_string(), base_url: "http://default-backend.example".to_string(),
workspace_id: "workspace-from-root".to_string(), workspace_id: "workspace-from-backend".to_string(),
} }
); );
} }
@@ -2654,29 +2821,26 @@ backend = "shared"
} }
#[test] #[test]
fn parse_plugin_list_and_show() { fn plugin_cli_rejects_ambient_catalog_commands_and_options() {
match parse_args_from(["plugin", "list", "--workspace=/tmp/ws", "--json"]).unwrap() { for args in [
Mode::Plugin(plugin_cli::PluginCliCommand::List(options)) => { vec!["plugin", "list"],
assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws"))); vec!["plugin", "show", "project:echo"],
assert!(options.json); vec!["plugin", "check", "plugin", "--workspace=/tmp/ws"],
} vec!["plugin", "check", "plugin", "--profile", "project:inspect"],
_ => panic!("expected Plugin list mode"), ] {
let error = parse_args_from(args).unwrap_err();
assert!(
error.0.contains("unknown yoi plugin"),
"unexpected error: {error}"
);
} }
match parse_args_from([ match parse_args_from(["plugin", "check", "plugin.yoi-plugin", "--json"]).unwrap() {
"plugin", Mode::Plugin(plugin_cli::PluginCliCommand::Check { input, args }) => {
"show", assert_eq!(input, PathBuf::from("plugin.yoi-plugin"));
"project:echo", assert!(args.json);
"--profile",
"project:inspect",
])
.unwrap()
{
Mode::Plugin(plugin_cli::PluginCliCommand::Show { reference, args }) => {
assert_eq!(reference, "project:echo");
assert_eq!(args.profile.as_deref(), Some("project:inspect"));
} }
_ => panic!("expected Plugin show mode"), _ => panic!("expected Plugin check mode"),
} }
} }
@@ -2916,8 +3080,15 @@ backend = "shared"
#[test] #[test]
fn parse_panel_rejects_removed_host_local_restore_path() { fn parse_panel_rejects_removed_host_local_restore_path() {
let err = let err = parse_args_from([
parse_args_from(["--backend", "http://127.0.0.1:8787", "panel", "-r"]).unwrap_err(); "--backend",
"http://127.0.0.1:8787",
"--workspace-id",
"workspace-a",
"panel",
"-r",
])
.unwrap_err();
assert!(err.to_string().contains("removed host-local Worker path")); assert!(err.to_string().contains("removed host-local Worker path"));
} }
} }
File diff suppressed because it is too large Load Diff
+409
View File
@@ -0,0 +1,409 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use client::{
BackendWorkspaceCatalogTarget, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest,
create_backend_workspace, list_backend_workspace_repositories_blocking,
list_backend_workspaces_blocking,
};
use super::{ParseError, client_global_config_path};
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct InitOptions {
pub(crate) backend_url: String,
pub(crate) display_name: String,
pub(crate) repository_key: String,
pub(crate) repository_root: PathBuf,
pub(crate) default_ref: Option<String>,
}
pub(crate) async fn run_init(
options: InitOptions,
) -> Result<workspace_api::WorkspaceCreateResponse, ParseError> {
let repository_uri = options
.repository_root
.to_str()
.ok_or_else(|| ParseError("the repository root is not valid UTF-8".to_string()))?;
let target = BackendWorkspaceCatalogTarget {
base_url: options.backend_url.clone(),
};
let response = create_backend_workspace(
&target,
&CreateBackendWorkspaceRequest {
operation_key: uuid::Uuid::now_v7().to_string(),
display_name: options.display_name,
repository: CreateBackendWorkspaceRepository {
repository_key: options.repository_key,
uri: repository_uri.to_string(),
default_ref: options.default_ref,
},
},
)
.await
.map_err(|error| ParseError(format!("Backend rejected Workspace creation: {error}")))?;
record_workspace_backend_routing(&response.workspace.workspace_id, &options.backend_url)?;
Ok(response)
}
pub(crate) fn select_backend_workspace_for_repository(
base_url: &str,
repository_path: &Path,
) -> Result<String, ParseError> {
let repository = discover_open_repository(repository_path)?;
let target = BackendWorkspaceCatalogTarget {
base_url: base_url.to_string(),
};
let workspaces = list_backend_workspaces_blocking(&target).map_err(|error| {
ParseError(format!(
"failed to query Backend Workspace catalog: {error}"
))
})?;
let mut catalog = Vec::with_capacity(workspaces.len());
for workspace in workspaces {
let repositories =
list_backend_workspace_repositories_blocking(&target, &workspace.workspace_id)
.map_err(|error| {
ParseError(format!(
"failed to query Repository catalog for Workspace '{}': {error}",
workspace.display_name
))
})?;
catalog.push((workspace, repositories));
}
select_workspace_from_repository_catalog(&repository, &catalog)
}
pub(crate) fn discover_repository_root(path: &Path) -> Result<PathBuf, ParseError> {
Ok(discover_open_repository(path)?.root)
}
fn select_workspace_from_repository_catalog(
repository: &OpenRepositoryIdentity,
catalog: &[(
workspace_api::WorkspaceSummary,
Vec<workspace_api::RepositorySummary>,
)],
) -> Result<String, ParseError> {
let matches = catalog
.iter()
.filter(|(_, repositories)| {
repositories
.iter()
.any(|candidate| repository.matches(&candidate.source))
})
.map(|(workspace, _)| workspace)
.collect::<Vec<_>>();
match matches.as_slice() {
[workspace] => Ok(workspace.workspace_id.clone()),
[] => Err(ParseError(
"the current Git repository is not registered in any accessible Workspace; run `yoi init` or pass `--workspace-id` explicitly"
.to_string(),
)),
matches => {
let names = matches
.iter()
.map(|workspace| workspace.display_name.as_str())
.collect::<Vec<_>>()
.join(", ");
Err(ParseError(format!(
"the current Git repository matches multiple accessible Workspaces ({names}); pass `--workspace-id` explicitly"
)))
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct OpenRepositoryIdentity {
root: PathBuf,
remote_uris: Vec<String>,
}
impl OpenRepositoryIdentity {
fn matches(&self, source: &workspace_api::RepositorySource) -> bool {
match source.kind {
workspace_api::RepositorySourceKind::LocalPath => fs::canonicalize(&source.uri)
.ok()
.is_some_and(|path| path == self.root),
workspace_api::RepositorySourceKind::File => source
.uri
.strip_prefix("file://")
.and_then(|path| fs::canonicalize(path).ok())
.is_some_and(|path| path == self.root),
workspace_api::RepositorySourceKind::Ssh
| workspace_api::RepositorySourceKind::Https => self
.remote_uris
.iter()
.any(|uri| normalize_git_uri(uri) == normalize_git_uri(&source.uri)),
workspace_api::RepositorySourceKind::Invalid => false,
}
}
}
fn discover_open_repository(path: &Path) -> Result<OpenRepositoryIdentity, ParseError> {
let root = git_stdout(path, ["rev-parse", "--show-toplevel"])?;
let root = fs::canonicalize(root.trim()).map_err(|error| {
ParseError(format!(
"failed to resolve current Git repository root: {error}"
))
})?;
let remote_uris = Command::new("git")
.arg("-C")
.arg(&root)
.args(["remote", "get-url", "--all", "origin"])
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| {
String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default();
Ok(OpenRepositoryIdentity { root, remote_uris })
}
fn git_stdout<const N: usize>(path: &Path, args: [&str; N]) -> Result<String, ParseError> {
let output = Command::new("git")
.arg("-C")
.arg(path)
.args(args)
.output()
.map_err(|error| ParseError(format!("failed to execute Git: {error}")))?;
if !output.status.success() {
return Err(ParseError(
"the current directory is not inside a readable Git repository".to_string(),
));
}
String::from_utf8(output.stdout)
.map_err(|_| ParseError("Git returned a non-UTF-8 repository path".to_string()))
}
fn normalize_git_uri(uri: &str) -> String {
let uri = uri.trim().trim_end_matches('/');
uri.strip_suffix(".git").unwrap_or(uri).to_string()
}
fn record_workspace_backend_routing(
workspace_id: &str,
backend_url: &str,
) -> Result<(), ParseError> {
let path = client_global_config_path().ok_or_else(|| {
ParseError("unable to resolve the global client configuration directory".to_string())
})?;
record_workspace_backend_routing_at(&path, workspace_id, backend_url)
}
fn record_workspace_backend_routing_at(
path: &Path,
workspace_id: &str,
backend_url: &str,
) -> Result<(), ParseError> {
let mut config = match fs::read_to_string(path) {
Ok(raw) => toml::from_str::<toml::Value>(&raw).map_err(|error| {
ParseError(format!(
"failed to parse global client config {}: {error}",
path.display()
))
})?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
toml::Value::Table(toml::map::Map::new())
}
Err(error) => {
return Err(ParseError(format!(
"failed to read global client config {}: {error}",
path.display()
)));
}
};
let table = config
.as_table_mut()
.ok_or_else(|| ParseError("global client config must be a TOML table".to_string()))?;
let backends = table
.entry("backends")
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
.as_table_mut()
.ok_or_else(|| ParseError("global client config `backends` must be a table".to_string()))?;
let existing_name = backends.iter().find_map(|(name, value)| {
value
.get("url")
.and_then(toml::Value::as_str)
.filter(|url| *url == backend_url)
.map(|_| name.clone())
});
let backend_name = existing_name.unwrap_or_else(|| {
let mut candidate = "init".to_string();
let mut suffix = 2_u32;
while backends.contains_key(&candidate) {
candidate = format!("init-{suffix}");
suffix += 1;
}
backends.insert(
candidate.clone(),
toml::Value::Table(toml::map::Map::from_iter([(
"url".to_string(),
toml::Value::String(backend_url.to_string()),
)])),
);
candidate
});
table
.entry("default_connection")
.or_insert_with(|| toml::Value::String("backend".to_string()));
table
.entry("default_backend")
.or_insert_with(|| toml::Value::String(backend_name.clone()));
let workspaces = table
.entry("workspaces")
.or_insert_with(|| toml::Value::Table(toml::map::Map::new()))
.as_table_mut()
.ok_or_else(|| {
ParseError("global client config `workspaces` must be a table".to_string())
})?;
workspaces.insert(
workspace_id.to_string(),
toml::Value::Table(toml::map::Map::from_iter([(
"backend".to_string(),
toml::Value::String(backend_name),
)])),
);
let parent = path.parent().ok_or_else(|| {
ParseError("global client config path has no parent directory".to_string())
})?;
fs::create_dir_all(parent).map_err(|error| {
ParseError(format!(
"failed to create global client config directory {}: {error}",
parent.display()
))
})?;
let encoded = toml::to_string_pretty(&config)
.map_err(|error| ParseError(format!("failed to encode global client config: {error}")))?;
let temporary = path.with_extension(format!("toml.tmp-{}", uuid::Uuid::now_v7()));
fs::write(&temporary, encoded).map_err(|error| {
ParseError(format!(
"failed to write global client config {}: {error}",
temporary.display()
))
})?;
fs::rename(&temporary, path).map_err(|error| {
let _ = fs::remove_file(&temporary);
ParseError(format!(
"failed to publish global client config {}: {error}",
path.display()
))
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{ClientDefaultConnection, read_client_config_from_global_path};
fn workspace_summary(id: &str, name: &str) -> workspace_api::WorkspaceSummary {
workspace_api::WorkspaceSummary {
workspace_id: id.to_string(),
owner_account_id: "owner-account".to_string(),
display_name: name.to_string(),
state: "active".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
}
}
fn repository_summary(
source: workspace_api::RepositorySource,
) -> workspace_api::RepositorySummary {
workspace_api::RepositorySummary {
repository_key: "main".to_string(),
kind: "git".to_string(),
provider: "builtin:git".to_string(),
source,
source_revision: 1,
source_fingerprint: "fingerprint".to_string(),
observed_status: workspace_api::RepositoryObservedStatus::Unverified,
observed_at: None,
default_selector: Some("develop".to_string()),
record_authority: "server_db".to_string(),
git: None,
diagnostics: None,
}
}
#[test]
fn repository_catalog_selection_handles_single_zero_and_multiple_matches() {
let repository = tempfile::tempdir().unwrap();
Command::new("git")
.args(["init", "-q"])
.current_dir(repository.path())
.status()
.unwrap();
let identity = discover_open_repository(repository.path()).unwrap();
let source = workspace_api::RepositorySource {
kind: workspace_api::RepositorySourceKind::LocalPath,
uri: identity.root.display().to_string(),
};
let matching_repository = repository_summary(source);
let workspace_a = workspace_summary("workspace-a", "Workspace A");
let workspace_b = workspace_summary("workspace-b", "Workspace B");
assert_eq!(
select_workspace_from_repository_catalog(
&identity,
&[(workspace_a.clone(), vec![matching_repository.clone()])],
)
.unwrap(),
"workspace-a"
);
assert!(
select_workspace_from_repository_catalog(&identity, &[])
.unwrap_err()
.to_string()
.contains("not registered in any accessible Workspace")
);
let multiple = select_workspace_from_repository_catalog(
&identity,
&[
(workspace_a, vec![matching_repository.clone()]),
(workspace_b, vec![matching_repository]),
],
)
.unwrap_err()
.to_string();
assert!(multiple.contains("matches multiple accessible Workspaces"));
assert!(multiple.contains("--workspace-id"));
}
#[test]
fn init_routing_config_survives_reload_without_repository_local_state() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("client.toml");
record_workspace_backend_routing_at(&path, "workspace-a", "http://backend.example")
.unwrap();
let first = read_client_config_from_global_path(Some(&path))
.unwrap()
.unwrap();
let second = read_client_config_from_global_path(Some(&path))
.unwrap()
.unwrap();
assert_eq!(first.default_connection, ClientDefaultConnection::Backend);
assert_eq!(first.default_backend.as_deref(), Some("init"));
assert_eq!(
first
.workspaces
.get("workspace-a")
.and_then(|entry| entry.backend.as_deref()),
Some("init")
);
assert_eq!(second, first);
assert!(!temp.path().join(".yoi/workspace.toml").exists());
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ It is not a dumping ground for external research, old plans, API inventories, or
6. [`design/flow-state-graph.md`](design/flow-state-graph.md) — Workspace Flow sources, immutable revisions, transition attempts, and bounded internal verification. 6. [`design/flow-state-graph.md`](design/flow-state-graph.md) — Workspace Flow sources, immutable revisions, transition attempts, and bounded internal verification.
7. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources. 7. [`design/profiles-manifests-prompts.md`](design/profiles-manifests-prompts.md) — reusable Profiles, resolved Manifests, and prompt resources.
8. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope. 8. [`design/tool-permissions-scope.md`](design/tool-permissions-scope.md) — tool policy and filesystem scope.
9. [`design/plugin-packages.md`](design/plugin-packages.md) — plugin package distribution, discovery, and enablement boundaries. 9. [`design/plugin-packages.md`](design/plugin-packages.md) — the retained offline package format and the prohibition on ambient Plugin authority.
10. [`development/plugin-development.md`](development/plugin-development.md) — how to build, package, enable, and inspect Yoi Plugins. 10. [`development/plugin-development.md`](development/plugin-development.md) — explicit-path offline Plugin package authoring and inspection.
11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records. 11. [`design/memory-knowledge.md`](design/memory-knowledge.md) — generated memory and audit records.
12. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions. 12. [`design/workspace-kanban-orchestrator-runtime.md`](design/workspace-kanban-orchestrator-runtime.md) — how Kanban operations become durable orchestration events and backend-internal routing decisions.
13. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary. 13. [`design/workspace-runtime-docker.md`](design/workspace-runtime-docker.md) — the WebUI / Backend / Runtime split, Docker image layout, worker launch path, and workdir materialization boundary.
+20 -168
View File
@@ -1,177 +1,29 @@
# Plugin Component Model migration # Plugin Component Model boundary
Yoi's original Plugin Tool runtime used a narrow core-WebAssembly ABI. That was the right MVP shape because it made sandboxing, bounded input/output, and fail-closed host imports explicit, but it is no longer the public authoring interface. Dynamic Worker Plugin execution is not part of the current product. The `.yoi-plugin` component metadata retained in `manifest` is an offline package-format contract only.
The supported runtime kind is now `wasm-component`, using the WebAssembly Component Model for Plugin Tool authoring and host APIs. Component Model adoption means Plugin interfaces are described as typed WIT worlds and lowered through the canonical ABI, instead of every Plugin author or SDK wrapper hand-writing pointer/length memory plumbing. ## Current behavior
## What Component Model changes - Worker creation and restore install no dynamic Plugin modules.
- Manifest/Profile input rejects `plugins` and `feature.plugins`.
- Runtime and Server startup perform no repository, ancestor, cwd, or user-data Plugin discovery.
- No persisted local `package_path` is execution authority.
- Only statically compiled trusted built-in Features contribute Worker capabilities.
- `yoi plugin check` parses an explicitly named directory or package without instantiating a component.
A core Wasm module exposes low-level functions and memory. Yoi's current Plugin Tool ABI is shaped like this: The package validator may reject legacy core-Wasm artifacts and require Component Model metadata, but passing validation does not make an artifact installable or executable.
```text ## Future platform constraints
export memory
export yoi_tool_call() -> i32
import yoi:tool/tool_name_len() -> i32
import yoi:tool/tool_name_read(ptr, len) -> i32
import yoi:tool/input_len() -> i32
import yoi:tool/input_read(ptr, len) -> i32
import yoi:tool/output_write(ptr, len) -> i32
```
This is small and auditable, but it makes raw ABI details part of the authoring model. A Component Model world can instead describe a typed contract: A future Server Plugin platform may execute Wasmtime Component Model packages only after the architecture is implemented as a coherent authority boundary:
```wit 1. an operator installs an immutable package into Server-owned artifact authority;
package yoi:plugin; 2. a Workspace owner selects an installed package through an immutable Addon revision;
3. Backend authors a per-Worker execution plan containing exact identities, digests, configuration, and bounded grants;
4. Runtime fetches only Server-authorized digests and verifies package bytes and execution-plan identity;
5. Runtime instantiates a fresh bounded Wasmtime Store with no ambient WASI authority; and
6. restore uses the persisted execution plan and exact artifact rather than current Workspace settings or a filesystem path.
interface tool { Default components receive no filesystem, sockets, environment, clocks, randomness, subprocess, Workdir, broad Workspace client, credential, or network authority. Any host import must be narrow, typed, explicitly granted, live-revalidated where necessary, bounded, and audited.
record request {
tool-name: string,
input-json: string,
}
record response { This future platform must not restore `.yoi/plugins`, user-data catalogs, cwd/ancestor discovery, native dynamic libraries, downloaded Cargo manifests, or local paths as compatibility authority.
output-json: string,
}
variant tool-error {
invalid-input(string),
denied(string),
failed(string),
}
run: func(req: request) -> result<response, tool-error>;
}
world tool-plugin {
export tool;
}
```
The exact WIT is still design work, but the important boundary is fixed: the Plugin author sees typed values and generated bindings; the host sees typed imports/exports; Yoi still enforces package enablement and Plugin grants outside the component.
## External patterns considered
Common Wasm extension systems normally ship more than a runtime:
- Extism-style systems provide host runtimes plus language PDKs. Plugin authors write normal typed functions while the PDK hides the raw ABI and host functions remain explicit.
- Spin-style systems combine a manifest, language SDK/templates, default-deny outbound/file capabilities, and Wasm components.
- wasmCloud-style systems separate components from capability providers and connect them through typed interfaces.
- The Component Model standardizes the interface layer with WIT and canonical ABI so host APIs can be versioned and bindings generated across languages.
The shared lesson is that a usable Wasm Plugin system needs a manifest, explicit capabilities, generated or hand-written SDK bindings, examples/templates, inspection tooling, and a versioned ABI. Yoi already has the manifest/discovery/enablement/grant/runtime foundation; the missing long-term piece is the typed component authoring interface.
## Yoi policy
Adopting the Component Model must not change Yoi's authority model:
- Package discovery is inventory only and does not register or execute a Plugin.
- Explicit enablement is required before any Tool surface is registered.
- Plugin grants are required before runtime execution and before `https` / `fs` / future host API calls.
- Component imports are not authority by themselves; host-side grant checks remain authoritative.
- Tool calls and Tool results continue through the ordinary ToolRegistry and Engine history path.
- No hidden context injection is introduced by component imports, resources, prompts, or SDK helpers.
- Plugin SDKs and templates are authoring aids, not trust boundaries.
## Migration shape
`runtime.kind = "wasm-component"` is the sole public Plugin runtime kind. Legacy raw core-Wasm declarations (`kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`) are rejected by manifest validation and are surfaced only as bounded diagnostics; they are not active/eligible Plugins and are not executed.
The migration is now focused on the component surface:
1. Keep WIT packages/worlds for Tool Plugin and initial host APIs versioned under `resources/plugin/wit`.
2. Keep manifest/schema support centered on `runtime.kind = "wasm-component"`.
3. Keep the component runtime backend and typed host import/export binding as the active execution path.
4. Port future host API designs to WIT-compatible interfaces.
5. Keep the Rust PDK/template aligned with the component world.
## Runtime/backend caution
The legacy core-Wasm implementation used `wasmi` as a transitional backend. The active Plugin Tool runtime is now selected by package runtime metadata and executed through `wasmtime::component`; discovery and static inspection must continue to avoid executing package code.
Keep the component backend selected by package runtime metadata and Profile/feature policy. Do not make all Plugin packages depend on component execution during discovery or inspection.
## Relationship to pending host APIs
`https` and `fs` host API Tickets should avoid baking in raw pointer/length interfaces as the long-term authoring contract. If they land before the component runtime, implement them in a way that can be represented as WIT records/results later, and document raw ABI wrappers as transitional.
For example, `https` should be modeled as typed request/response data with explicit grant checks for host/method/path/body bounds. `fs` should be modeled as scoped read/list/write operations with path normalization and root-escape rejection. Those concepts translate well to WIT.
## Non-goals
- Component Model adoption does not imply WASI filesystem/network access.
- It does not replace Plugin grants with WIT imports.
- It does not introduce Service, Ingress, WebSocket, or inbound HTTP by itself.
- It does not merge Plugin and MCP. MCP remains a separate untrusted tool/resource/prompt bridge with its own policy.
## Implemented runtime boundary
Plugin Tool packages now select the runtime explicitly in `plugin.toml`:
```toml
[runtime]
kind = "wasm-component"
component = "plugin.component.wasm"
world = "yoi:plugin/tool@1.0.0"
```
Legacy core-Wasm metadata is accepted only far enough to produce migration diagnostics: package checks and discovery reject `kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`, `list`/`show` report those packages as rejected rather than active/eligible, and the active runtime path does not execute them.
The component runtime uses `wasmtime::component` and expects the exported world
`yoi:plugin/tool@1.0.0` with a `call(tool-name: string, input-json: string) ->
string` export. The returned string is the normal ToolOutput JSON, so
registration and execution still flow through the existing ToolRegistry and
Engine Tool-result history path.
Host imports are stable names under `yoi:host/*@1.0.0`; the repository WIT files
live in `resources/plugin/wit/`. Importing `yoi:host/request@1.0.0` or
`yoi:host/fs@1.0.0` is not authority. The runtime checks package grants before
component instantiation and checks again on every host call. No WASI filesystem,
network, environment, or other ambient imports are linked.
Static discovery and `yoi plugin list/show` only parse package manifests and
reported runtime metadata. They do not instantiate or execute the component.
Wrong `world`, missing artifact metadata, missing `call` export, unsupported
imports, or core-Wasm bytes in a component package all fail closed with bounded
Plugin diagnostics or ordinary Tool errors.
See `docs/examples/plugin-component-tool/lib.rs` and the embedded
`resources/plugin/templates/rust-component-tool/` starter for the preferred
Rust PDK authoring path. `yoi-plugin-pdk` is guest-side only: it re-exports
`wit-bindgen`, provides typed JSON input/output helpers, renders bounded
`ToolError` values as ordinary ToolOutput JSON, and does not depend on host
runtime crates or grant authority. Package authors should generate bindings from
`resources/plugin/wit`, build a component artifact, and set the component
runtime metadata above.
### v1 request/response shape
The v1 component world intentionally keeps Tool input, Tool output, and host API
payloads as JSON strings. This is a migration bridge that preserves the existing
ToolOutput schema, Tool history behavior, grant checks, and raw-Wasm host API
semantics while moving package authors onto WIT/canonical ABI bindings.
Structured WIT records for Tool requests/responses/errors and host request/FS
payloads are deferred to a follow-up API-design step rather than accidentally
omitted.
## Instance lifecycle surface
The first instance-capable world is `yoi:plugin/instance@1.0.0`. It moves
runtime ownership from per-Tool artifact execution to a host-managed
`PluginInstance`. The same instance handles Tool, Service, and Ingress surfaces,
so Plugin state/config/diagnostics can be shared without bypassing Yoi's normal
authority model.
Important boundaries:
- Tool calls still enter through `ToolRegistry` and return ordinary `ToolOutput`
that is visible in the Engine history path.
- Service and Ingress grants are separate from Tool grants. Sharing an instance
does not authorize a surface that lacks its own `surface.*` and per-surface
permission/grant.
- Ingress delivery accepts bounded typed untrusted events and returns explicit
JSON to the host. It does not call model Tools or mutate LLM context/history.
- Legacy raw-wasm and `yoi:plugin/tool@1.0.0` component packages are adapted
behind `PluginInstanceRegistry` for compatibility rather than executed through
a separate authority path.
- Host APIs such as `https` and `fs` remain independently grant-gated and still
reject ambient filesystem/network authority.
+20 -208
View File
@@ -1,219 +1,31 @@
# Plugin packages and discovery # Plugin packages
Plugin packages are a distribution format, not an authority boundary. A package can be found on disk, inspected, validated, and cached without registering any Hook, exposing any Tool, starting any process, or initializing any WASM module. Yoi retains `.yoi-plugin` as an offline authoring and inspection format. The format is not a Worker capability source.
The initial goal is a durable `.yoi-plugin` package format that later Tickets can implement in independent layers: discovery, archive validation/cache materialization, manifest/profile enablement, Plugin permission policy, declarative hooks, WASM runtime support, and any future MCP bridge. ## Current product boundary
## Package shape Normal Worker creation, restore, Profile/Manifest resolution, Server/Runtime startup, and CLI execution do not discover Plugin catalogs from:
A `.yoi-plugin` file is a single-file archive. The archive format is a constrained ZIP profile because it is easy to inspect without executing code and can carry text manifests, WebAssembly Component Model modules, schemas, and license material. - repository or ancestor `.yoi/plugins` directories;
- user-data Plugin directories;
- the current working directory; or
- persisted local `package_path` values.
The archive root must contain `plugin.toml` directly at the root. Packages should not require a wrapping directory whose name must match the plugin id. `plugins` and `feature.plugins` are rejected in Worker Manifest/Profile input. Dynamic Plugin Tools, Services, Ingress handlers, and WASM components are not installed or executed. Worker capabilities come only from statically compiled trusted built-in Features.
Recommended root layout: ## Offline format operations
```text The CLI keeps only operations whose input or destination is explicit:
plugin.toml # required package manifest
plugin.component.wasm # required when plugin.toml declares the component runtime ```sh
hooks/*.toml # optional declarative hook definitions yoi plugin new rust-component-tool ./example-plugin
schemas/*.schema.json # optional JSON schemas for configuration or tool input/output yoi plugin check ./example-plugin
README.md # recommended human description yoi plugin pack ./example-plugin --output ./example-plugin.yoi-plugin
LICENSE* # recommended license text yoi plugin check ./example-plugin.yoi-plugin
assets/** # optional non-executable data assets
``` ```
The package layout is intentionally data-first. Placing a package in a store must never execute `module.wasm`, register hook metadata, or scan assets as prompts. Those steps happen only after explicit enablement and policy resolution. These commands parse, validate, or write the named local artifact. They do not scan a Workspace, mutate Profile/Manifest configuration, install a package, register Worker Tools, or execute Plugin code. `plugin list` and `plugin show` were removed because their catalog semantics depended on ambient repository and user-data stores.
## `plugin.toml` ## Future installation authority
`plugin.toml` is the package authority for package identity and declared needs. It is not the authority for runtime grants. Server-installed Plugin packages and Addons are future work. That platform must provide explicit immutable package identity and digest, Server-owned artifact delivery and Workspace selection, a Backend-authored per-Worker execution plan, Runtime verification, and sandboxed execution. It must not restore repository-local or user-data discovery as a compatibility fallback.
Currently implemented strict `plugin.toml` shape:
```toml
schema_version = 1
id = "example.summarizer"
name = "Example Summarizer"
version = "0.1.0"
description = "Adds a custom summary command."
surfaces = ["hook"]
[[hooks]]
id = "summary"
file = "hooks/summary.md"
```
The package archive must contain both root `plugin.toml` and referenced runtime/content entries. Component runtime metadata is explicit and static inspection never executes the artifact:
```toml
[runtime]
kind = "wasm-component"
component = "plugin.component.wasm"
world = "yoi:plugin/tool@1.0.0"
```
`wasm-component` is the public/recommended runtime kind, recorded in [Plugin Component Model migration](plugin-component-model.md). Legacy raw core-Wasm declarations (`kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`) are retired: manifest validation rejects them and CLI inspection reports the package as rejected rather than active/eligible.
First-pass fields accepted by the parser:
- `schema_version`: required integer; unsupported versions fail closed.
- `id`: required unqualified local id. It is scoped by the source that discovered the package; it is not globally unique by itself.
- `name`, `version`, `description`: human metadata used in listings and diagnostics.
- `surfaces`: optional declared contribution surface names.
- `runtime`: optional component runtime metadata. Discovery records metadata and never executes it; unsupported/retired runtime kinds fail closed.
- `hooks`: optional hook metadata. Discovery records metadata and does not register hooks.
Future descriptor sections such as `[package]`, `[permissions]`, richer `contributions`, or `runtime.kind = "declarative"` are aspirational and are intentionally rejected by the current strict parser until implemented safely.
The `source` is not read from `plugin.toml`. It is assigned by the store that discovered the package.
## Stores, sources, and trust
Discovery should scan explicit stores and attach a source kind to each package:
- `builtin:<id>`: packages shipped with Yoi or installed as part of the binary distribution.
- `user:<id>`: packages discovered under `${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/`.
- `project:<id>`: packages discovered under `<workspace>/.yoi/plugins/`.
Packages under `${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/` or `<workspace>/.yoi/plugins/` are discovery only. Their presence is never permission to register Hooks or Tools, initialize WASM, start processes, open files, use network providers, read secrets, or launch MCP servers.
Trust differs by source, but none of the sources is self-authorizing:
- Builtin packages can be trusted as shipped code/data, but still require explicit enablement for a Worker/Profile when they affect runtime behavior.
- User packages are local user-installed artifacts and should be visible to workspaces, but they cannot bypass manifest/profile/tool/scope/secret policy.
- Project packages are repository-controlled artifacts and should be treated as untrusted until explicitly enabled by local policy. Cloning a repository must not be enough to execute a package.
## Identity and selector rules
Runtime identity is source-qualified: `builtin:<id>`, `user:<id>`, and `project:<id>` are distinct plugins even when `<id>` is the same string.
Durable enablement records should use source-qualified ids. Ambiguous unqualified ids fail closed. The implementation may offer convenience listing or search by bare id, but any operation that enables a package, grants permission, pins a digest, or records restored runtime state should require the fully qualified id.
Collision handling:
- Two packages with the same source-qualified id in the same effective store set are a discovery diagnostic and neither candidate is enabled implicitly.
- A `user:example` package does not override `builtin:example` unless a future explicit override rule says so.
- A `project:example` package does not override `user:example` or `builtin:example` by name alone.
## Discovery versus enablement
Discovery is a read-only inventory operation. It may report package metadata, validation errors, source, canonical store path, and deterministic digest. It must not initialize any runtime contribution.
Enablement is a resolved runtime plan. It should come from Profile/manifest configuration or another explicit local policy layer, then be recorded into the resolved Manifest/session metadata used to start the Worker. Restored Workers should use that resolved enabled-plugin plan instead of silently re-running fresh discovery and picking newer packages. Fresh discovery must not silently upgrade a restored Worker.
A minimal implemented enablement record is shaped like this. `version` is an exact package-version requirement; richer range constraints are deferred. `digest` is optional in authoring config, but fresh startup records the resolved digest into runtime metadata.
```toml
[[plugins.enabled]]
id = "user:example"
version = "0.1.0" # optional exact package-version requirement
digest = "sha256:..." # optional pin in authoring, resolved in runtime metadata
config = { level = "concise" }
```
If no digest is pinned in authoring, fresh startup may resolve the newest acceptable discovered package according to explicit policy. Once a Worker is started, the resolved manifest/session metadata should record the exact source-qualified id and digest so restore is stable.
## Permissions and grants
Plugin permission declarations are requests, not grants. Effective grants are the result of Plugin-layer policy combined with existing Yoi authority layers:
- resolved manifest/profile plugin enablement;
- Plugin policy for the source-qualified package id and deterministic digest;
- normal tool permission policy;
- filesystem scope checks;
- web provider enablement and network safety checks;
- secret references and secret-store policy;
- runtime limits for WASM or other execution engines.
The Plugin package permission model must not reuse `worker::feature` HostAuthority or grant concepts. The feature layer is an API/contribution substrate; it is not a security boundary for untrusted plugin packages. Plugin grants need their own explicit policy that can fail closed before a Hook, Tool, WASM host function, provider bridge, or external runtime is exposed.
When a package requests authority outside policy, diagnostics should explain the denied category and package identity without leaking raw secret values, environment contents, full private config, or large plugin-provided text.
## Archive safety and materialization
Archive handling should validate before runtime use:
- Reject absolute paths, `..`, empty segments, Windows drive prefixes, NUL bytes, duplicate normalized paths, and paths that normalize outside the package root.
- Reject symlinks, hardlinks, device files, special files, and entries that are not regular files or directories.
- Enforce bounded extraction: maximum archive size, maximum expanded size, maximum entry count, maximum per-file size, and a compression-ratio limit.
- Validate every manifest-referenced path against the normalized entry set.
- Decode text manifests as UTF-8 and bound diagnostic excerpts.
- Ignore or normalize archive metadata such as mtimes, owners, groups, and executable bits; these should not affect runtime authority.
After validation, compute a deterministic digest over the normalized materialized package, not over incidental ZIP ordering or timestamps. A stable digest input should include the format version, normalized relative path, file length, and file content hash for each regular file in sorted order.
Runtime should materialize packages into a digest-keyed cache, for example:
```text
<cache>/plugins/sha256-<hex>/
plugin.toml
module.wasm
...
```
Initialization should read from the digest-keyed cache, not directly from the mutable user/workspace store. This makes restore, diagnostics, and lock/pin behavior reproducible.
Optional lock behavior can be added in a later Ticket:
- an authoring-time pin in Profile/manifest configuration;
- a workspace lock file recording source-qualified id, version, source store, digest, and selected package path;
- restore metadata that records the actual digest used by the Worker.
A lock or pin is selection authority, not execution authority. Enablement and grants are still required.
## Diagnostics
Diagnostics should be safe, bounded, and attributable:
- Include source-qualified id when available, source kind, validation phase, and digest when computed.
- Prefer canonical store-relative paths or redacted absolute paths; avoid dumping large path lists.
- Never print raw secret values, provider tokens, environment dumps, or plugin-supplied opaque payloads.
- Treat package metadata and README text as untrusted content when showing it to an LLM or UI.
- Report discovery errors without disabling unrelated valid packages.
## Runtime notes
Declarative hooks are data contributions. Loading a declarative hook still requires explicit package enablement. Hook text should enter the system through the normal Hook/Engine paths, preserving the rule that model-affecting inputs are committed to history before they affect context when applicable.
WASM packages should initialize only from the digest-keyed cache after enablement and grant resolution. The host should use a narrow ABI, bounded memory, fuel/time limits, bounded output, and explicit host functions. A WASM module must not inherit filesystem, network, tool, secret, process, or MCP authority from the package store path.
Tool contributions from plugins should pass through the normal ToolRegistry and permission checks. Plugin-provided schemas can describe arguments, but schema presence is not permission to execute a tool.
## MCP boundary
MCP remains a separate feature-backed integration and is out of the initial Plugin package runtime. A `.yoi-plugin` package must not launch an MCP server or imply MCP enablement.
A future MCP/plugin bridge would need its own Ticket covering external process authority, lifecycle, permission mapping, resource/prompt operations, diagnostics, and trust model. Until then, package metadata may mention compatibility for humans, but runtime packaging should ignore it.
## Follow-up implementation cuts
Good follow-up Tickets are intentionally separable:
1. Manifest/Profile plugin enablement schema and resolved-session metadata, including restore behavior and digest pins.
2. Package discovery for builtin, user, and project stores with source-qualified identity and collision diagnostics.
3. `.yoi-plugin` archive validation, deterministic digest computation, and digest-keyed cache materialization.
4. Plugin-layer permission policy that combines package requests with existing tool/scope/web/secret/runtime allowlists without using `worker::feature` HostAuthority concepts.
5. Declarative hook package loading from enabled, materialized packages.
6. WASM package ABI, initialization limits, host-function grants, and Tool/Hook contribution plumbing.
7. Optional lock-file or pin update workflow for reproducible fresh startup.
8. Future MCP/plugin bridge, only if explicitly approved as a separate design and implementation effort.
### Component Model Tool runtime
Tool packages may use WebAssembly Component Model runtime metadata:
```toml
[runtime]
kind = "wasm-component"
component = "plugin.component.wasm"
world = "yoi:plugin/tool@1.0.0"
```
For new Rust Tool packages, the preferred authoring path is the first-party
`yoi-plugin-pdk` plus the embedded `resources/plugin/templates/rust-component-tool/`
starter. The template uses a checkout-local path dependency for development and
documents a future out-of-tree pinned git `rev` dependency pattern. Crates.io
publication, remote template fetching, and package authoring commands are not
part of the current package/runtime contract.
Legacy raw core-Wasm metadata remains documented only as a rejected migration diagnostic. Packages must not use `entry`/`abi`; discovery reports `kind = "wasm"` / `abi = "yoi-plugin-wasm-1"` packages as rejected without executing the artifact. Component execution still requires explicit package enablement, exact source/version/digest grants, and matching Tool/host API permissions.
+3 -3
View File
@@ -25,9 +25,9 @@ A resolved Manifest is the concrete contract used to create or restore a Worker.
Source/partial layers may omit fields. Resolved manifests should be explicit enough that Worker creation does not depend on ambient configuration later changing under it. Source/partial layers may omit fields. Resolved manifests should be explicit enough that Worker creation does not depend on ambient configuration later changing under it.
`--manifest <path>` exists as an explicit low-level escape hatch. Normal fresh startup should select a Profile through `profiles.toml` / builtin defaults rather than ambient manifest cascades. `--manifest <path>` exists as an explicit low-level escape hatch. Normal fresh startup selects a `builtin:*` or `project:*` Profile from the Backend-managed Workspace Config revision rather than applying an ambient manifest cascade.
For normal Profile/default startup, a workspace may add `.yoi/override.local.toml` as a final local manifest layer. Yoi discovers the nearest ancestor `.yoi/override.local.toml` from the workspace base used for profile resolution, resolves relative paths in that file against its containing `.yoi` directory, and applies it after the selected Profile and builtin defaults. This file is intended for machine-local choices such as provider/model, worker language, prompt pack, and permission policy tweaks; it is ignored by git via the repository `*.local.*` rule. It is not applied in explicit `--manifest <path>` mode, and it cannot set `worker.name` because Worker identity remains a runtime input. Project Profiles are evaluated from the revisioned Virtual Config's Decodal source/import closure. The Backend packages that closure into a digest-bound Profile source archive, delivers it with the resolved launch bundle, and the Worker persists the resulting Manifest for restore. Files below the Workdir are not implicit Profile override layers.
## Local stdio MCP server declarations ## Local stdio MCP server declarations
@@ -77,7 +77,7 @@ Prompts live under `resources/prompts` so builtins, project overrides, and user
The prompt layer should explain policy and behavior, but it should not smuggle volatile state into model context. Runtime facts that affect later turns must still go through history. The prompt layer should explain policy and behavior, but it should not smuggle volatile state into model context. Runtime facts that affect later turns must still go through history.
Builtin resources should be embedded at compile time. User/project profiles, explicit profile paths, prompt overlays, provider/model overrides, and explicit manifests remain filesystem-based. Builtin resources should be embedded at compile time. Project Profiles and prompt overlays belong to the Backend-managed revisioned Workspace Config and travel as digest-bound source archives. User Profile registries, provider/model catalog overrides, and explicit low-level Manifests remain filesystem-based where those local resolution paths are used.
## Why this separation matters ## Why this separation matters
+3 -1
View File
@@ -31,7 +31,9 @@ worker
## CLI / TUI routing ## CLI / TUI routing
- `yoi` の connection-aware command は `TargetKind::Standalone | Backend` の二択で dispatch する。`--local` と client config の `default_connection = "local"` は Standalone を選ぶ入力であり、旧 LocalBackend を有効化しない。 Client config は repository `.yoi/client.config.toml` を読まず、repository `.yoi/workspace.toml` は Backend Workspace identity が必要な場合だけ参照する - `yoi` の connection-aware command は `TargetKind::Standalone | Backend` の二択で dispatch する。`--local` は Standalone を、明示的な CLI selector、または `$XDG_CONFIG_HOME/yoi/client.toml``default_connection = "backend"``default_backend` は Backend を選ぶ。repository-local `.yoi/workspace.toml` / `.yoi/client.config.toml` は connection や Workspace selection の authority ではない
- `yoi init --display-name <NAME> --repository-key <KEY>` は現在の Git repository root を Server DB に登録し、`$XDG_CONFIG_HOME/yoi/client.toml` に Backend routing のみを保存する。repository-local `.yoi/workspace.toml` は作成しない。
- Backend workflow で Workspace selector が省略された場合、client は現在の Git repository の canonical local source または `origin` URI を Server DB の Repository catalog と照合する。exactly one の Workspace だけを選択し、zero/multiple match や Backend offline は明示的に失敗して repository-local file へ fallback しない。
- Standalone の通常起動は `StandaloneHost`、restore は専用 `StandaloneStore` の session picker を使う。Workspace Worker list、PID、Unix socket、subprocess は探索しない。 - Standalone の通常起動は `StandaloneHost`、restore は専用 `StandaloneStore` の session picker を使う。Workspace Worker list、PID、Unix socket、subprocess は探索しない。
- `workers`、Backend Worker restore、Workspace panel、Ticket、Objective は Backend authority を要求する。Standalone から repository-local filesystem backend へ fallback しない。 - `workers`、Backend Worker restore、Workspace panel、Ticket、Objective は Backend authority を要求する。Standalone から repository-local filesystem backend へ fallback しない。
- `yoi worker` は Runtime や明示的な process-owned integration が使う direct Worker entrypoint として残るが、通常の `yoi` / TUI 起動経路からは呼び出さない。 - `yoi worker` は Runtime や明示的な process-owned integration が使う direct Worker entrypoint として残るが、通常の `yoi` / TUI 起動経路からは呼び出さない。
+2
View File
@@ -24,6 +24,8 @@ Responsibilities are split as follows:
The Backend can project Runtime and Worker state, but it should not become a hidden filesystem/runtime implementation. Runtime observations should be reconstructable from Runtime APIs and committed Backend records. The Backend can project Runtime and Worker state, but it should not become a hidden filesystem/runtime implementation. Runtime observations should be reconstructable from Runtime APIs and committed Backend records.
Remote Runtime authentication follows the same Workspace boundary. The Server signs each Runtime request with the target Workspace signing identity, and the Runtime verifies it against the installed Workspace issuer bundle. Runtime-to-Server source proof is signed by the Runtime identity and uses the bundle's Backend URL as audience. Server-global signing identities, Runtime-side global Server trust, and static bearer fallback are not Remote Workspace authority. Provisioning and rotation are described in [Workspace ↔ Runtime authentication](../development/server-runtime-auth.md).
## Docker image layout ## Docker image layout
Docker images are built through Nix `dockerTools.buildImage`, not through a root Dockerfile. Docker images are built through Nix `dockerTools.buildImage`, not through a root Dockerfile.
+9 -27
View File
@@ -4,35 +4,15 @@ This repository is developed with Yoi itself. Dogfooding is valuable because it
## Pre-restart gate ## Pre-restart gate
Never use the live dogfood Server or Runtime as the first startup test for a new Never use the live dogfood Server or Runtime as the first startup test for a new binary. A dogfood restart is allowed only after this sequence succeeds:
binary. A dogfood restart is allowed only after this sequence succeeds:
1. Build the production entrypoints: 1. Build the production entrypoints: `cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`.
`cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`. 2. Run the focused and dependent tests for the changed contracts, followed by workspace-root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`.
2. Run the focused and dependent tests for the changed contracts, followed by 3. Exercise the provisioning and operational checks in [Workspace ↔ Runtime authentication](server-runtime-auth.md) against isolated Server DB, Runtime data, and ports. The Workspace owner must create the binding, the Runtime operator must install the Workspace issuer bundle, and the challenge proof must become verified.
`cargo fmt --all -- --check` and `git diff --check HEAD`. 4. Have an external supervisor or operator restart Server and Runtime at the same generation. A Worker hosted by the target Runtime must never terminate its own Runtime.
3. Run `scripts/isolated-startup-smoke.sh` from an external shell/process. 5. Verify post-restart readiness through the Workspace Runtime projection, ping, Worker list/create, protocol subscription, and a Runtime-to-Server source-proof operation before treating the environment as healthy.
4. Inspect any failed run's retained `/tmp/yoi-isolated-startup-smoke.*` logs;
do not restart dogfood until the cause is fixed and the smoke passes.
5. Have an external supervisor or operator restart Server and Runtime. A Worker
hosted by the target Runtime must never terminate its own Runtime.
6. Verify post-restart readiness through the Workspace Runtime projection and a
real restored Worker operation before treating the environment as healthy.
The smoke harness runs the normal `yoi-server` and `yoi-runtime` binaries using The former isolated startup shell harness depended on removed Server-global trust commands and is intentionally not a fallback smoke path. New automated startup coverage must provision the same Workspace-scoped binding and challenge authority used by production rather than recreating global trust or seeding private authority directly.
separate `HOME`, `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, temporary Git repository,
Server database, Runtime fs store, identity/trust material, and non-dogfood
ports. It fails if either port is already occupied, if state escapes the
temporary root, if a process exits unexpectedly, if Runtime readiness is not
visible through Server, or if startup logs contain a panic, migration collision,
or Worker execution restore failure. It also proves that a listening Server
without its configured Runtime is not readiness and restarts the isolated
Runtime once to exercise persistence reopen.
Override `YOI_SMOKE_SERVER_BIN`, `YOI_SMOKE_RUNTIME_BIN`,
`YOI_SMOKE_SERVER_PORT`, or `YOI_SMOKE_RUNTIME_PORT` only when a separate build
or port is intentionally under test. Set `YOI_SMOKE_KEEP=1` to retain successful
artifacts. Failed artifacts are retained automatically.
## What to record ## What to record
@@ -45,6 +25,8 @@ A report is useful when it explains:
- what design boundary was missing - what design boundary was missing
- what evidence was observed - what evidence was observed
For a Remote Runtime rollout, also record the source commit, binary generation, Server schema version, Runtime binding revision, Workspace key generation, and typed HTTP/WebSocket outcomes. A successful document response does not outweigh visible UI, console, or API errors.
## Runtime command caveat ## Runtime command caveat
After rebuilding and restarting during dogfooding, `current_exe()` can point at a deleted binary path. Use typed runtime-command configuration and the development-only `YOI_POD_RUNTIME_COMMAND` executable override rather than reviving shell-command overrides. After rebuilding and restarting during dogfooding, `current_exe()` can point at a deleted binary path. Use typed runtime-command configuration and the development-only `YOI_POD_RUNTIME_COMMAND` executable override rather than reviving shell-command overrides.
+29 -451
View File
@@ -1,467 +1,45 @@
# Plugin development # Plugin package authoring
This guide is for building a Yoi Plugin outside the Yoi runtime codebase. It describes the current Plugin package shape, how to author a Tool Plugin, how to enable it in a workspace, and how to inspect/debug it. Dynamic Worker Plugins are not currently installable or executable. This guide covers only the retained offline `.yoi-plugin` authoring format.
Yoi Plugins are intentionally explicit. The Plugin system is designed around the following host-side principles: ## Commands
- package discovery is inventory only; putting a package in `.yoi/plugins` does not enable, register, or execute it; Every retained command uses an explicit local input or destination:
- a Profile/config entry must explicitly enable each Plugin package by source-qualified id, version, and digest;
- Plugin grants must allow each surface and host API before registration or execution can use it;
- Plugin code runs only through the configured sandbox runtime;
- Plugin packages do not inherit Worker workspace filesystem, network, environment, or Ticket authority;
- Tool calls and Tool results use the ordinary Yoi Tool/Engine history path;
- Plugin metadata, output, and diagnostics are untrusted unless Yoi host policy says otherwise.
## Design intent ```sh
yoi plugin new rust-component-tool ./example-plugin
Yoi's Plugin platform is meant to make extension behavior reviewable before it becomes model-visible. A Plugin package should answer four separate questions: yoi plugin new rust-component-service ./example-service
yoi plugin check ./example-plugin
1. **What is this package?** `plugin.toml` declares identity, version, runtime, surfaces, requested permissions, and Tool schemas. yoi plugin pack ./example-plugin --output ./example-plugin.yoi-plugin
2. **Is it enabled here?** Workspace/Profile config chooses exact package refs and pinned digests. yoi plugin check ./example-plugin.yoi-plugin
3. **What may it do?** Plugin grants authorize Tool surfaces and host APIs such as `https` and `fs`.
4. **How does it interact with the model?** Tool schemas/results enter through ordinary ToolRegistry and Tool history paths.
Keep these layers separate when designing a Plugin. Do not make package discovery imply enablement. Do not make SDK/PDK convenience imply authority. Do not treat Rust helper APIs or host API wrappers as permission grants. The host always re-checks authority at registration/execution/API-call boundaries.
Yoi's preferred Plugin shapes are **Tool first** for request/response capabilities and **Service/Ingress** for host-dispatched inbound events. A good Tool Plugin has a narrow schema, deterministic input/output behavior, explicit side-effect metadata, and a minimal grant set. A Service Plugin should keep long-lived transport ownership in the host and react to bounded ingress events by returning output commands.
Component Model authoring is the supported path for Plugins. Legacy raw core-Wasm manifests (`kind = "wasm"` / `abi = "yoi-plugin-wasm-1"`) are retired and rejected by `yoi plugin check`, discovery, `list`, and `show`; use the Rust PDK/template and `kind = "wasm-component"` instead.
## Current status
Implemented foundation:
- package discovery from project/user Plugin stores;
- explicit enablement resolution;
- Tool surface registration;
- Plugin permission grants;
- Component Model Tool runtime;
- first-party Rust PDK helpers for Component Model Tool guests;
- embedded Rust Component Tool starter template;
- `https` and `fs` host APIs for Tool runtime;
- read-only `yoi plugin list/show` inspection;
- local first-party authoring commands: `yoi plugin new`, `yoi plugin check`, and `yoi plugin pack`.
Still intentionally limited or separate from this guide:
- multi-language SDK/PDK crates;
- Service / Ingress surfaces, where the host owns transport lifecycle, dispatches bounded ingress events, and consumes output commands such as `websocket_send`;
- WebSocket or inbound HTTP for bidirectional external event integrations;
- public registry/install/update/signature tooling.
## Package locations
Yoi discovers `.yoi-plugin` packages from:
```text
<workspace>/.yoi/plugins/*.yoi-plugin
${XDG_DATA_HOME:-~/.local/share}/yoi/plugins/*.yoi-plugin
``` ```
Use project packages for workspace-specific Plugins and user packages for personal reusable Plugins. Project packages should normally be committed only when the package content is safe and intended to be part of the project. - `new` writes an embedded starter template to the named destination and refuses unsafe or non-empty destinations.
- `check` parses and validates the named directory or package without executing Plugin code.
- `pack` validates the named directory and writes a deterministic constrained archive.
## Package archive format `list`, `show`, `--workspace`, and `--profile` are intentionally unavailable. They previously implied ambient Workspace/user catalog discovery.
A `.yoi-plugin` package is currently a bounded ZIP archive. For now, create it with stored entries, not compressed entries: ## Safety and authority
```bash Offline package commands do not:
(cd my-plugin && zip -0 -r ../example.echo.yoi-plugin plugin.toml plugin.component.wasm)
```
The archive root must contain `plugin.toml`. Runtime files referenced by the manifest must also be inside the archive. Yoi rejects path traversal, root escapes, malformed manifests, unsupported API/runtime versions, and other unsafe archive shapes. - inspect repository or ancestor `.yoi/plugins` directories;
- inspect a user-data Plugin store;
- enable or install a package;
- mutate Profile or Manifest configuration;
- register Worker Tools, Services, or Ingress handlers;
- instantiate or execute a WASM component; or
- grant filesystem, network, secret, Ticket, or Workspace authority.
## Authoring CLI `plugins` and `feature.plugins` are rejected by current Worker Manifest/Profile resolution. Statically compiled built-in Features are the only current Worker capability source.
Use the local authoring commands for first-party deterministic authoring. These commands never fetch remote templates, never run Plugin code, never mutate enablement configuration, and never generate or embed secrets. ## Package format
Create a Rust Component Tool starter from embedded resources: A package directory contains `plugin.toml` plus the files named by that manifest. A packed `.yoi-plugin` uses the constrained deterministic archive format documented in [`../design/plugin-packages.md`](../design/plugin-packages.md). Validation rejects malformed metadata, unsafe paths, links, unsupported entries, bounds violations, digest inconsistencies, and legacy raw core-Wasm runtime declarations.
```bash Generated templates include a placeholder `plugin.component.wasm`. Replace it with a real built component before `check` can report the package as verified. A verified package is still only an offline artifact; verification does not install or authorize it.
yoi plugin new rust-component-tool ./my-plugin
# or, for a host-dispatched Service/Ingress example:
yoi plugin new rust-component-service ./my-service-plugin
```
`new` writes only inside the requested destination and refuses an existing non-empty destination or destination symlink. The generated template includes `plugin.toml`, Rust source, Cargo metadata, README next steps, and a placeholder `plugin.component.wasm` artifact so local `check`/`pack` validation can run immediately. Replace the placeholder with a real built component before enabling or executing the Plugin. ## Future Server Plugin platform
Validate a source directory or an existing `.yoi-plugin` archive: Do not copy packages into repository or user-data catalogs. Future installation must go through Server-owned package/artifact authority, immutable identity and digest selection, Backend-authored Worker execution plans, Runtime verification, and sandboxed execution. That platform is separate work and must not reintroduce filesystem catalog fallback.
```bash
yoi plugin check ./my-plugin
yoi plugin check ./my-plugin --json
yoi plugin check ./my-plugin.yoi-plugin --json
```
`check` performs bounded static validation of the directory/archive shape, manifest, runtime declaration, referenced artifact presence, Tool schemas, permission declarations, host API declarations, archive safety, and deterministic digest when a package can be materialized. Component-world validation is metadata-only: it verifies the declared world string and runtime manifest shape, but it does not instantiate or execute the component. A generated placeholder component produces `status = "partial"` plus a diagnostic and is not enablement-ready until replaced. Invalid checks print the same structured report and exit non-zero.
Pack a source directory into a deterministic stored `.yoi-plugin` archive:
```bash
yoi plugin pack ./my-plugin
yoi plugin pack ./my-plugin --output ./my-plugin.yoi-plugin --json
```
`pack` rejects malformed manifests, missing runtime artifacts, symlinks/root escapes, and unsupported package shapes. The JSON output contains the stable package reference, output path, digest, entries, and safety flags. After review, copy the package to `.yoi/plugins/` (or the user Plugin store) and add explicit Profile/config enablement with pinned digest and grants; packing and checking do not do this for you.
## Designing a Plugin
Design a Plugin around the smallest reviewable contract that is useful to the model.
For Tool Plugins:
- expose one clear operation per Tool name;
- keep the input schema narrow and explicit;
- make side effects visible in the Tool name, description, and `external_write` / permission metadata;
- request only the host APIs needed for that Tool;
- prefer deterministic, structured output over conversational prose;
- return bounded summaries and content that are useful as Tool results;
- avoid hiding long workflows, background daemons, or inbound event handling inside a Tool call.
A Tool should be a capability the model may choose to call, not a second agent runtime. If the desired behavior needs a long-lived connection, incoming events, or autonomous routing, put the transport lifecycle behind a Service/Ingress surface and let the host dispatch bounded events; do not stretch the Tool surface into a hidden polling loop.
Design package permissions as a review surface. A reviewer should be able to read `plugin.toml` plus the enablement grants and understand:
- what Tools become model-visible;
- what external side effects are possible;
- what hosts or paths can be touched;
- what data can flow back into ordinary Tool results.
## Manifest: `plugin.toml`
A minimal Component Model Tool Plugin manifest looks like this:
```toml
schema_version = 1
id = "example.echo"
name = "Example Echo"
version = "0.1.0"
surfaces = ["tool"]
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "example_echo" },
]
[runtime]
kind = "wasm-component"
component = "plugin.component.wasm"
world = "yoi:plugin/tool@1.0.0"
[[tools]]
name = "example_echo"
description = "Echo input text."
input_schema = { type = "object", properties = { text = { type = "string" } }, required = ["text"], additionalProperties = false }
external_write = false
```
`wasm-component` is the public runtime kind. Legacy raw core-Wasm declarations such as `kind = "wasm"` / `abi = "yoi-plugin-wasm-1"` are no longer compatibility paths: static validation rejects them with a bounded diagnostic and they are not displayed as active/eligible Plugins.
Do not rely on package presence to activate anything. Discovery only records inventory.
## Rust PDK authoring
Rust authoring with `yoi-plugin-pdk` is the supported path for new Tool Plugins. Raw core-Wasm ABI packages are retired and should be rewritten as Component Model packages before enabling.
Create a starter with:
```bash
yoi plugin new rust-component-tool ./my-plugin
# or, for a host-dispatched Service/Ingress example:
yoi plugin new rust-component-service ./my-service-plugin
```
The generated package contains:
- `Cargo.toml` with a checkout-local `yoi-plugin-pdk` path dependency;
- `src/lib.rs` with the runtime binding setup and typed JSON Tool handling;
- `plugin.toml` targeting `kind = "wasm-component"`;
- README next steps and the out-of-tree pinned git `rev` dependency pattern.
For an independent Plugin repository, replace the checkout-local path dependency with a pinned Yoi source revision. Use the repository root `.git` URL, not the browser `/src/branch/...` URL, and pin `rev` instead of tracking a moving branch:
```toml
[dependencies]
serde = { version = "1.0", features = ["derive"] }
yoi-plugin-pdk = { git = "https://gitea.hareworks.net/Hare/yoi.git", package = "yoi-plugin-pdk", rev = "<pinned-yoi-commit-sha>" }
```
As a Plugin author, treat the generated binding setup as template code. Edit the typed input/output structs and handler function rather than hand-writing runtime ABI glue.
The important authoring shape is:
```rust
use serde::{Deserialize, Serialize};
use yoi_plugin_pdk::{ToolContext, ToolError, ToolOutput};
#[derive(Deserialize)]
struct EchoInput {
text: String,
}
#[derive(Serialize)]
struct EchoOutput<'a> {
tool: &'a str,
text: String,
}
fn handle_echo(ctx: ToolContext, input: EchoInput) -> Result<ToolOutput, ToolError> {
ToolOutput::json(
format!("{} ok", ctx.tool_name()),
EchoOutput {
tool: ctx.tool_name(),
text: input.text,
},
)
}
yoi_plugin_pdk::export_component_tool!(Plugin, handle_echo);
```
The PDK parses the runtime input string into a typed Rust value, passes a `ToolContext` containing the selected Tool name, and serializes `ToolOutput` JSON accepted by the current component runtime. `ToolError` values are structured and bounded, then rendered through the ordinary Tool result path; the component cannot inject hidden context.
The PDK is guest-side only. It does not depend on Yoi host/runtime crates and does not grant filesystem, network, or environment authority. Host-side Plugin manifests and explicit enablement grants remain the authority boundary for Tool execution and for host APIs such as `https` and `fs`.
The expected authoring flow is Rust-first: generate the starter, edit `src/lib.rs`, replace the local path dependency with a pinned `git` + `rev` dependency when the Plugin lives outside the Yoi checkout, build the Rust component artifact for `plugin.component.wasm`, run `yoi plugin check`, then `yoi plugin pack`. Crates.io publication and remote template fetching are intentionally deferred. Use `yoi plugin list/show` to inspect the packaged/enabled state before trying to execute the Tool.
## Enabling a Plugin in a workspace
Enablement belongs in the resolved Profile/config path for the workspace. For local dogfooding or private experiments, use the ignored local overlay rather than committing secrets or local paths:
```toml
# .yoi/override.local.toml
[features]
plugins = true
[[plugins.enabled]]
id = "project:example.echo"
version = "0.1.0"
digest = "sha256:<digest from yoi plugin show/list>"
surfaces = ["tool"]
[plugins.enabled.grants]
id = "project:example.echo"
version = "0.1.0"
digest = "sha256:<same digest>"
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "example_echo" },
]
```
A source-qualified id is preferred:
```text
project:example.echo
user:example.echo
builtin:example.echo
```
Unqualified ids can be ambiguous and should fail closed when more than one source matches.
## Inspecting Plugins
Use the read-only CLI inspection commands first:
```bash
yoi plugin list
yoi plugin list --json
yoi plugin show project:example.echo
yoi plugin show project:example.echo --json
```
`list/show` must not execute Plugin code. They are intended to explain static state:
- discovered packages;
- enabled vs disabled packages;
- missing packages referenced by enablement;
- invalid manifests;
- digest/version/source mismatches;
- granted/denied permissions;
- Tool registration eligibility;
- runtime metadata.
Typical statuses:
```text
active enabled and statically valid for at least one surface/tool
disabled discovered but not explicitly enabled
missing enablement references a package that is not discovered
rejected invalid manifest, incompatible API, digest mismatch, grant denial, etc.
partial usable package with some rejected surfaces/tools
```
## `request` host API
The `request` host API is a one-shot outbound HTTP request API. It is meant for bounded Tool calls such as JSON POSTs or REST requests. It is not a WebSocket, SSE/event-stream, gateway, daemon, or inbound HTTP surface; persistent transports require a separate Plugin capability.
Manifest permissions should request `host_api.request` in addition to the Tool permissions, and the package manifest must statically declare the URL targets it may call. Enablement grants must then allow the API and grant matching request targets. A grant without a matching manifest target is unsafe/unused and is shown as ineligible rather than expanding authority.
Example manifest shape:
```toml
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "http_post_json" },
{ kind = "host_api", api = "request" },
]
[[request]]
scheme = "https"
host = "api.example.com"
methods = ["POST"]
path_prefixes = ["/v1/"]
```
Example enablement grant shape:
```toml
[plugins.enabled.grants]
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "http_post_json" },
{ kind = "host_api", api = "request" },
]
[[plugins.enabled.grants.request]]
scheme = "https"
host = "api.example.com"
methods = ["POST"]
path_prefixes = ["/v1/"]
```
Yoi checks method, scheme, host, optional port, and path prefix against both the manifest declaration and enablement grant before any network I/O. `http://localhost`, loopback, private, and other local targets are never ambient; they require an explicit manifest request target and an explicit matching grant. The explicit request target is the declared URL authority; a granted DNS hostname may resolve to a loopback/private address without requiring a separate literal-IP grant, so reviewers should grant hostnames only when that resolution behavior is intended. Broad targets such as `host = "*"` are supported only as visibly broad request permissions in inspection/diagnostics. Embedded credentials, credential-like headers, oversize requests/responses, WebSocket URLs/upgrades, and SSE/event-stream requests are rejected.
## Service ingress and output commands
Service Plugins export the `yoi:plugin/instance@1.0.0` world. The host starts one Plugin instance, owns external ingress transports, and calls `handle_ingress(name, event_json)` with bounded event envelopes. A WebSocket ingress event contains fields such as `kind`, `source`, `ingress_name`, `payload`, `created_at`, `attempt`, and `correlation_id`; the Rust PDK maps this to `PluginIngressEvent`.
Service handlers return `ServiceOutput`, not ordinary ToolOutput. Side effects are requested through top-level `output_commands`. For a WebSocket reply, use the PDK helper:
```rust
ServiceOutput::websocket_send(
&event,
"reply-1",
event.source.strip_prefix("websocket:").unwrap_or(&event.source),
"pong",
)
```
This serializes a `websocket_send` command with `source_event_id`, `command_id`, `payload.url`, `payload.text`, and a request timestamp. The host parses, bounds, grant-checks, and dispatches the command through the host-owned WebSocket driver. Do not create a long-running guest receive loop for Service integrations; incoming messages should arrive as ingress events.
A minimal manifest shape is:
```toml
surfaces = ["tool", "service", "ingress"]
permissions = [
{ kind = "surface", surface = "service" },
{ kind = "service", name = "example_service" },
{ kind = "surface", surface = "ingress" },
{ kind = "ingress", name = "example_ws" },
{ kind = "host_api", api = "websocket" },
]
[runtime]
kind = "wasm-component"
world = "yoi:plugin/instance@1.0.0"
component = "plugin.component.wasm"
[[services]]
name = "example_service"
description = "Host-managed service instance."
lifecycle = "host-managed"
[[ingresses]]
name = "example_ws"
description = "Handles host-owned WebSocket text events."
event_kinds = ["websocket_text", "websocket_close", "websocket_error"]
sources = ["websocket:wss://gateway.example.com/gateway"]
input_schema = { type = "object" }
[[websocket]]
scheme = "wss"
host = "gateway.example.com"
path_prefixes = ["/gateway"]
```
The `host_api.websocket` permission and `[[websocket]]` target are required for `websocket_send` output commands. Runtime enablement grants must explicitly allow the same WebSocket target; the manifest declaration alone is not authority.
Generate a fuller example with `yoi plugin new rust-component-service ./my-service-plugin`.
## `websocket` host API
The `websocket` host API is a separate grant-gated capability named `host_api.websocket`, not an extension of `host_api.request`. It opens host-owned WebSocket connections only when both the package manifest and enablement config declare matching targets. Tool-style/internal bounded use can still drive the lifecycle explicitly through `open`, `send-text`, `recv`, and `close`; incoming messages are returned only from bounded `recv` calls and are not injected into model context, history, Dashboard state, or Ticket state. Service Plugins should prefer the host-owned Service WebSocket driver instead of running a long-lived guest recv loop: declare a Service ingress source as `websocket:wss://host/path`, include the `websocket_text`/`websocket_close`/`websocket_error` event kinds you want delivered, and emit the Service output command `websocket_send` to send text back through the same grant-checked host connection.
Example manifest shape:
```toml
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "gateway_step" },
{ kind = "host_api", api = "websocket" },
]
[[websocket]]
scheme = "wss"
host = "gateway.example.com"
path_prefixes = ["/gateway"]
```
Example enablement grant shape:
```toml
[plugins.enabled.grants]
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "gateway_step" },
{ kind = "host_api", api = "websocket" },
]
[[plugins.enabled.grants.websocket]]
scheme = "wss"
host = "gateway.example.com"
path_prefixes = ["/gateway"]
```
Yoi checks scheme (`ws`/`wss`), host, optional port, and path prefix against both declarations before opening the connection. Loopback/private/local targets are not ambient; they require explicit matching manifest and grant entries. Broad WebSocket targets such as `host = "*"` are reported as broad WebSocket diagnostics. v1 is text-only: `send-text` requires UTF-8, binary receive fails closed, guest-supplied handshake headers and embedded URL credentials are rejected, and SecretRef-based credential/header injection is future work. The host bounds open descriptors, text/message size, receive timeout, connection count, handle lifetime, and cleanup on close/instance stop/drop.
## `fs` host API
The `fs` host API is Plugin-scoped and grant-gated. Plugins do not inherit the Worker/workspace filesystem authority automatically.
Example grant shape:
```toml
[plugins.enabled.grants]
permissions = [
{ kind = "surface", surface = "tool" },
{ kind = "tool", name = "read_notes" },
{ kind = "host_api", api = "fs" },
]
[[plugins.enabled.grants.fs]]
root = "/absolute/path/to/plugin-data"
operations = ["read", "list"]
```
Yoi normalizes paths, rejects `..` traversal, rejects symlink/root escapes, and applies read/write/list bounds. Diagnostics must not include file contents.
## Development checklist
1. Create a package directory with `plugin.toml` and the runtime artifact.
2. Build the Wasm/component artifact.
3. Package with stored ZIP entries as `.yoi-plugin`.
4. Put it under `.yoi/plugins/` or the user Plugin store.
5. Run `yoi plugin list` and `yoi plugin show <ref>`.
6. Add explicit enablement and grants.
7. Re-run `yoi plugin show <ref>` until status/diagnostics are correct.
8. Start Yoi with `features.plugins = true` in the resolved config/Profile.
9. Call the Tool and verify ordinary Tool result/history behavior.
## Safety rules for Plugin authors
- Do not assume ambient filesystem, network, or environment access.
- Do not put secrets in `plugin.toml` or package files.
- Request only the minimal host APIs and grants needed.
- Keep Tool output bounded and structured.
- Prefer Component Model authoring for new Plugins.
- Treat raw core-Wasm ABI support as retired; migration diagnostics may mention it, but authors should publish `wasm-component` packages.
+52 -242
View File
@@ -1,266 +1,76 @@
# Server / Runtime manual auth setup # Workspace ↔ Runtime 認証
Workspace Server and Worker Runtime authenticate remote Runtime control traffic with manually exchanged Ed25519 public keys and short-lived Server-signed capability tokens. Yoi の Remote Runtime 認証は Workspace ごとの署名 identity を authority とする。
Server-global な署名鍵や Runtime 側の trusted-Server catalog は使わない。
This is a non-interactive bootstrap flow. Commands fail when required flags are missing, and existing identity/trust records are not overwritten unless `--replace` is passed explicitly. ## Authority
## Authority boundary - Server DB は Workspace ごとの signing identity と Runtime binding を保持する。
- Runtime は `trust-workspace` で受理した `WorkspaceIssuerAuthorizationBundle` を保持する。
- bundle は `workspace_id`、Workspace key id/generation、Workspace public key、Backend URL、許可された Runtime identity を固定する。
- Server → Runtime の各 HTTP / WebSocket request は、対象 Workspace の signing identity で短命な capability token を発行する。
- Runtime は request method、`path_and_query`、body digest、permission、Workspace、Runtime、key generation、expiry、JTI を検証する。
- Runtime → Server の source proof は Runtime identity で署名し、対象 Workspace と bundle の Backend URL を audience に固定する。
- Server は現在の Workspace Runtime binding、Runtime public key、Backend public URL、request target、body digest、permission、expiry、replay state を検証する。
- Workspace Server is the workspace control plane. It owns trusted Runtime records in the Server DB and signs per-request Runtime capability tokens. 旧 Server identity/trust 管理 command と旧 Runtime-side Server trust command、旧 Runtime auth key flags は廃止済みである。これらに相当する Server-global trust を fallback として使ってはならない。
- Runtime owns Worker execution. It does not own a workspace registry or workspace list.
- Runtime API paths remain worker-centric; workspace scope is carried in the signed auth context and enforced by Runtime-side authorization/filtering code.
- Browser/Web clients should talk to Workspace Server, not directly to Runtime.
## Identifiers used in examples ## Provisioning
Replace these values for the deployment: 1. Runtime identity を初期化する。
```text ```sh
SERVER_ID=server-main yoi-runtime identity init --runtime-id <runtime-id>
RUNTIME_ID=runtime-main yoi-runtime identity show
RUNTIME_BASE_URL=http://127.0.0.1:38800 ```
```
`SERVER_ID` is the issuer id in Server-signed tokens. `RUNTIME_ID` is the token audience and must match the Runtime identity. 2. Workspace owner が Settings → Runtimes から Runtime public bundle と endpoint を登録する。
3. Server が Workspace issuer bundle と challenge を発行する。
4. operator が bundle を Runtime に追加する。
## 1. Create and show the Server identity ```sh
yoi-runtime trust-workspace add --bundle <workspace-issuer-bundle.json>
yoi-runtime trust-workspace show --workspace-id <workspace-id>
```
From the Workspace Server host: 5. Runtime が challenge proof を生成し、Workspace owner が Server に submit する。
6. Server が verified binding を commit した後、通常の Workspace-signed request が利用可能になる。
```bash 同じ Runtime identity は異なる Workspace から独立して信頼できる。trust record、replay protection、binding、失効はすべて Workspace scope で評価する。
yoi-server identity init --server-id server-main
```
Show the public identity and copy the `public_key` value: ## Runtime auth file
```bash `runtime-auth.toml` は Runtime identity と Workspace issuer records のみを authority とする。
yoi-server identity show --json 旧 Server trust entry は読み飛ばされ、以後の identity / `trust-workspace` 更新時に書き戻されない。旧 entry を残しても認証には使用されない。
```
The Server private identity is stored in the Yoi data directory under the Server data root, currently: `trust-workspace` の file store は次を fail closed で検証する。
```text - 最大 8 MiB
<data_dir>/server/identity.toml - 最大 4,096 records
``` - exact Workspace / Runtime identity
- key id/generation と public key fingerprint
- normalized Backend URL
- replace 時の expected current generation
- list は `offset` / `limit` 必須で、1 page 最大 100 records
On Unix this file is written with `0600` permissions. Do not copy the private key to Runtime or commit it to the repository. ## Local token
## 2. Create and show the Runtime identity `--local-token` は明示的な local Runtime 呼び出し専用であり、Remote Workspace binding の代替ではない。Workspace issuer auth が有効な Remote Runtime request は Workspace capability token を使う。
From the Runtime host, using the same Runtime storage flags that the Runtime server process will use: ## Rotation と失効
```bash Workspace signing key または Runtime key の変更は、現在 binding を置き換える明示的な provisioning 操作として行う。古い generation、古い Runtime key、revoked binding、失効済み token、replayed JTI は即時拒否する。
yoi-runtime identity init --runtime-id runtime-main
```
Show the public identity and copy the `public_key` value: Server の Runtime cache は現在の persisted binding 全体と照合する。endpoint、Runtime public key/fingerprint、binding revision、Workspace key generation の変更を検知した場合、stale client を利用しない。
```bash ## 運用確認
yoi-runtime identity show --json
```
By default, Runtime auth state is stored at: Remote Runtime を有効化した後は次を確認する。
```text 1. `yoi-runtime trust-workspace show --workspace-id <workspace-id>` が期待する bundle を表示する。
<data_dir>/runtime/auth.toml 2. Workspace Settings の Runtime binding が `verified` で、現在の key id/generation と verification evidence を表示する。
``` 3. Runtime ping、Worker list/create、`worker.protocol` subscription が Workspace-signed token で成功する。
4. wrong Workspace、wrong Runtime、wrong target/body、expired token、revoked/replaced binding、replayed JTI が拒否される。
5. Runtime → Server source proof が configured Backend public URL audience と一致し、spoofed headers だけでは認証されない。
If the Runtime process is launched with `--fs-root` or `--fs-runtime-dir`, pass the same flags to every `identity` and `trust-server` command. Otherwise the setup command may write an auth file that the server process never reads. Server / Runtime の再起動は live reload ではない authority 変更を反映するときだけ、通常の運用権限と migration gate に従って行う。実行中プロセスを開発 Worker が無断で停止してはならない。
Example with explicit Runtime storage:
```bash
yoi-runtime identity init \
--runtime-id runtime-main \
--fs-root /var/lib/yoi-runtime
yoi-runtime identity show \
--json \
--fs-root /var/lib/yoi-runtime
```
## 3. Register the Server public key on Runtime
On the Runtime host, register the Server public key copied from `yoi-server identity show --json`:
```bash
yoi-runtime trust-server add \
--server-id server-main \
--public-key '<SERVER_PUBLIC_KEY>'
```
With explicit Runtime storage, keep using the same storage flags:
```bash
yoi-runtime trust-server add \
--server-id server-main \
--public-key '<SERVER_PUBLIC_KEY>' \
--fs-root /var/lib/yoi-runtime
```
Verify:
```bash
yoi-runtime trust-server list --json
```
## 4. Register the Runtime public key and endpoint on Server
On the Workspace Server host, register the Runtime public key copied from `yoi-runtime identity show --json`:
```bash
yoi-server trust-runtime add \
--workspace-id '<WORKSPACE_ID>' \
--runtime-id runtime-main \
--base-url http://127.0.0.1:38800 \
--public-key '<RUNTIME_PUBLIC_KEY>' \
--display-name 'Runtime main'
```
This writes a Workspace-scoped Runtime binding and trust fingerprint to the Server DB. During `yoi-server serve`, active bindings are loaded as remote Runtime sources and receive signed capability tokens. Repository-external Runtime files are not registration or trust authority.
Verify:
```bash
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
```
## 5. Start Runtime and Workspace Server
Start Runtime with the same storage flags used during Runtime identity/trust setup:
```bash
yoi-runtime \
--bind 127.0.0.1:38800
```
For repository builds, the equivalent cargo command is:
```bash
cargo run -p worker-runtime \
--bin yoi-runtime \
-- --bind 127.0.0.1:38800
```
Start Workspace Server:
```bash
yoi-server serve --listen 127.0.0.1:8787
```
For repository builds:
```bash
cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
```
An empty Server DB is valid. Open the Web UI, create or authenticate the Account, and register the first Workspace through the normal Workspace creation flow. Server startup does not create a Workspace from its current working directory or repository-local configuration.
## Smoke checks
Check both trust stores:
```bash
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
yoi-runtime trust-server list --json
```
Check that Workspace Server can see Runtime workers through the authenticated path. From the CLI:
```bash
yoi workers \
--backend http://127.0.0.1:8787 \
--runtime-id runtime-main
```
In Web, open the Workspace UI through Workspace Server and verify that Runtime worker listing, worker creation, and Console protocol input work. The protocol WebSocket uses the same Server-signed Runtime auth path as REST control calls.
## Rotation and replacement
Identity and trust records are intentionally not overwritten by default.
Rotate Server identity:
```bash
yoi-server identity init --server-id server-main --replace
```
After Server identity rotation, every Runtime that trusts that Server must be updated with the new Server public key:
```bash
yoi-runtime trust-server add \
--server-id server-main \
--public-key '<NEW_SERVER_PUBLIC_KEY>' \
--replace
```
Rotate Runtime identity:
```bash
yoi-runtime identity init --runtime-id runtime-main --replace
```
After Runtime identity rotation, Server must be updated with the new Runtime public key:
```bash
yoi-server trust-runtime add \
--workspace-id '<WORKSPACE_ID>' \
--runtime-id runtime-main \
--base-url http://127.0.0.1:38800 \
--public-key '<NEW_RUNTIME_PUBLIC_KEY>' \
--replace
```
## Revocation
Revoke a trusted Runtime on Server:
```bash
yoi-server trust-runtime revoke \
--workspace-id '<WORKSPACE_ID>' \
--runtime-id runtime-main
```
Remove a trusted Server from Runtime:
```bash
yoi-runtime trust-server revoke --server-id server-main
```
## Troubleshooting
### `trusted runtimes are registered but server identity is not initialized`
The Server DB contains trusted Runtime records, but the Server signing identity file does not exist. Run:
```bash
yoi-server identity init --server-id server-main
```
If the identity was created in another environment, ensure the Server process is using the same Yoi data directory.
### Runtime accepts unauthenticated requests
Runtime only enables signed capability-token auth when both a Runtime identity and at least one trusted Server are present in its auth file. Check:
```bash
yoi-runtime identity show --json
yoi-runtime trust-server list --json
```
Also confirm the Runtime process was started with the same `--fs-root` / `--fs-runtime-dir` used for setup.
### Wrong audience or unauthorized Runtime response
Confirm the `--runtime-id` registered on Server exactly matches the Runtime identity id:
```bash
yoi-runtime identity show --json
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
```
`RUNTIME_ID` is the token audience; mismatches are rejected by Runtime.
### Duplicate registration fails
This is expected. Use `--replace` only when intentionally rotating or updating trust material.

Some files were not shown because too many files have changed in this diff Show More