Scope-Lockの実装
This commit is contained in:
parent
4ba58723dc
commit
a7b9b6fa4b
27
Cargo.lock
generated
27
Cargo.lock
generated
|
|
@ -735,6 +735,16 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs4"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8640e34b88f7652208ce9e88b1a37a2ae95227d84abec377ccd3c5cfeb141ed4"
|
||||
dependencies = [
|
||||
"rustix 1.1.4",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
|
|
@ -1388,9 +1398,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
|||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.184"
|
||||
version = "0.2.185"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
||||
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
|
|
@ -1893,8 +1903,10 @@ dependencies = [
|
|||
"chrono",
|
||||
"clap",
|
||||
"dotenv",
|
||||
"fs4",
|
||||
"futures",
|
||||
"include_dir",
|
||||
"libc",
|
||||
"llm-worker",
|
||||
"manifest",
|
||||
"minijinja",
|
||||
|
|
@ -2244,7 +2256,7 @@ dependencies = [
|
|||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.4.15",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3583,6 +3595,15 @@ dependencies = [
|
|||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.59.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
|
||||
dependencies = [
|
||||
"windows-targets",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
|
|
|
|||
|
|
@ -138,6 +138,22 @@ impl Scope {
|
|||
self.allow.iter().map(|r| r.target.as_path())
|
||||
}
|
||||
|
||||
/// Allow rules with their targets resolved to absolute paths.
|
||||
///
|
||||
/// Used by the scope-lock registry, where every Pod's allocation
|
||||
/// must be expressed in absolute terms so prefix comparisons are
|
||||
/// meaningful across processes.
|
||||
pub fn allow_rules(&self) -> Vec<ScopeRule> {
|
||||
self.allow
|
||||
.iter()
|
||||
.map(|r| ScopeRule {
|
||||
target: r.target.clone(),
|
||||
permission: r.permission,
|
||||
recursive: r.recursive,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Iterate over absolute paths granted `Write` by an allow rule.
|
||||
/// Subset of [`readable_paths`](Self::readable_paths).
|
||||
pub fn writable_paths(&self) -> impl Iterator<Item = &Path> {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ tools = { version = "0.1.0", path = "../tools" }
|
|||
minijinja = "2.19.0"
|
||||
chrono = "0.4.44"
|
||||
include_dir = "0.7.4"
|
||||
fs4 = { version = "0.13.1", features = ["sync"] }
|
||||
libc = "0.2.185"
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait = "0.1.89"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ pub mod controller;
|
|||
pub mod hook;
|
||||
pub mod notifier;
|
||||
pub mod runtime_dir;
|
||||
pub mod scope_lock;
|
||||
pub mod shared_state;
|
||||
pub mod socket_server;
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ use crate::notification_buffer::NotificationBuffer;
|
|||
use crate::notifier::Notifier;
|
||||
use crate::pod_interceptor::PodInterceptor;
|
||||
use crate::prompt_loader::PromptLoader;
|
||||
use crate::runtime_dir;
|
||||
use crate::scope_lock::{self, ScopeAllocationGuard, ScopeLockError};
|
||||
use crate::system_prompt::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||
use crate::usage_tracker::UsageTracker;
|
||||
use protocol::{NotificationLevel, NotificationSource};
|
||||
|
|
@ -105,6 +107,13 @@ pub struct Pod<C: LlmClient, St: Store> {
|
|||
/// injection into the next LLM request. Shared with the
|
||||
/// PodInterceptor installed in `ensure_interceptor_installed`.
|
||||
pending_notifications: NotificationBuffer,
|
||||
/// Scope allocation in the machine-wide lock file. `Some` for
|
||||
/// Pods built via `from_manifest` (production path); `None` for
|
||||
/// lower-level constructors (`Pod::new`, `Pod::restore`) that
|
||||
/// bypass the registry. Kept purely for its `Drop` impl, which
|
||||
/// releases the allocation when the Pod is dropped.
|
||||
#[allow(dead_code)]
|
||||
scope_allocation: Option<ScopeAllocationGuard>,
|
||||
}
|
||||
|
||||
impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||
|
|
@ -146,6 +155,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
system_prompt_template: None,
|
||||
notifier: None,
|
||||
pending_notifications: NotificationBuffer::new(),
|
||||
scope_allocation: None,
|
||||
};
|
||||
pod.apply_prune_from_manifest();
|
||||
Ok(pod)
|
||||
|
|
@ -195,6 +205,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
|||
system_prompt_template: None,
|
||||
notifier: None,
|
||||
pending_notifications: NotificationBuffer::new(),
|
||||
scope_allocation: None,
|
||||
};
|
||||
pod.apply_prune_from_manifest();
|
||||
Ok(pod)
|
||||
|
|
@ -875,6 +886,20 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
return Err(PodError::PwdOutsideScope { pwd });
|
||||
}
|
||||
|
||||
// Register this Pod in the machine-wide scope-lock registry
|
||||
// before building anything else, so a spawn that conflicts on
|
||||
// scope fails fast (and without having paid for client setup).
|
||||
let socket_path = runtime_dir::default_base()
|
||||
.map_err(ScopeLockError::from)?
|
||||
.join(&manifest.pod.name)
|
||||
.join("sock");
|
||||
let scope_allocation = scope_lock::install_top_level(
|
||||
manifest.pod.name.clone(),
|
||||
std::process::id(),
|
||||
socket_path,
|
||||
scope.allow_rules(),
|
||||
)?;
|
||||
|
||||
let client = provider::build_client(&manifest.provider)?;
|
||||
let mut worker = Worker::new(client);
|
||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||
|
|
@ -910,6 +935,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
|||
system_prompt_template,
|
||||
notifier: None,
|
||||
pending_notifications: NotificationBuffer::new(),
|
||||
scope_allocation: Some(scope_allocation),
|
||||
};
|
||||
pod.apply_prune_from_manifest();
|
||||
Ok(pod)
|
||||
|
|
@ -1058,6 +1084,9 @@ pub enum PodError {
|
|||
#[source]
|
||||
source: SystemPromptError,
|
||||
},
|
||||
|
||||
#[error(transparent)]
|
||||
ScopeLock(#[from] ScopeLockError),
|
||||
}
|
||||
|
||||
/// Canonicalize an absolute pwd (resolves symlinks and any `.`/`..`
|
||||
|
|
|
|||
|
|
@ -86,7 +86,11 @@ async fn atomic_write(target: &Path, content: &[u8]) -> Result<(), io::Error> {
|
|||
}
|
||||
|
||||
/// Resolve the default base directory for runtime data.
|
||||
fn default_base() -> Result<PathBuf, io::Error> {
|
||||
///
|
||||
/// Public so the scope-lock registry (which lives outside the
|
||||
/// `RuntimeDir` instance lifecycle) can predict a Pod's socket path
|
||||
/// without constructing a `RuntimeDir` first.
|
||||
pub fn default_base() -> Result<PathBuf, io::Error> {
|
||||
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
|
||||
Ok(PathBuf::from(runtime_dir).join("insomnia"))
|
||||
} else if let Ok(home) = std::env::var("HOME") {
|
||||
|
|
|
|||
1014
crates/pod/src/scope_lock.rs
Normal file
1014
crates/pod/src/scope_lock.rs
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,10 @@
|
|||
# Scope lock file: write 排他とスコープ分譲の記録基盤
|
||||
|
||||
## レビュー状態
|
||||
|
||||
初回レビュー実施済み。[scope-lock.review.md](scope-lock.review.md) を参照。
|
||||
指摘1件(ファイルパーミッション 0600 の明示設定)の修正を条件に受け入れ可。
|
||||
|
||||
## 背景
|
||||
|
||||
Pod オーケストレーションでは scope の分譲(spawner が自身の scope を spawned Pod に譲渡)が発生する。また、人間が独立に複数の Pod を起動した場合にも同一パスへの write 衝突を検出する必要がある。
|
||||
|
|
|
|||
100
tickets/scope-lock.review.md
Normal file
100
tickets/scope-lock.review.md
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
# レビュー: Scope lock file
|
||||
|
||||
対象差分: `crates/pod/src/scope_lock.rs` (新規 992行), `crates/manifest/src/scope.rs`, `crates/pod/src/{pod,lib,runtime_dir}.rs`, `crates/pod/Cargo.toml`(未コミット)
|
||||
|
||||
## 要件達成状況
|
||||
|
||||
| 要件 | 状態 |
|
||||
|---|---|
|
||||
| lock file に Pod の scope allocation を記録 | ✅ `register_pod` / `delegate_scope` で JSON に書き込み |
|
||||
| flock による排他アクセス | ✅ `LockFileGuard::open` で `lock_exclusive`、Drop で `unlock` |
|
||||
| write 衝突の検出 | ✅ `find_conflict_owner` が delegation tree を下降して真の所有者を特定 |
|
||||
| stale エントリの自動回収 (PID 死活) | ✅ `reclaim_stale` が `kill(pid, 0)` で判定、dead を削除・子を reparent |
|
||||
| scope 分譲の記録 (`delegated_from`) | ✅ `delegate_scope` が spawner の effective scope 内か検証してから登録 |
|
||||
| 分譲チェーンの reparent (A→B→D、B 死亡時 D を A に付け替え) | ✅ `release_pod` / `reclaim_stale` ともに `delegated_from` を親に付け替え |
|
||||
| Pod 正常終了時の allocation 解放 | ✅ `ScopeAllocationGuard` の Drop で `release_pod` を呼ぶ |
|
||||
| Pod 起動時 (`from_manifest`) に自動登録 | ✅ `scope_lock::install_top_level` を `from_manifest` 内で呼出 |
|
||||
| テストで衝突検出・stale 回収・分譲/返却を検証 | ✅ 16 テストケース |
|
||||
| パーミッション制御 (0600) | — チケットに記載あるが、ファイル作成時の umask 制御は未実装。`OpenOptions` に mode 設定なし |
|
||||
|
||||
## アーキテクチャ
|
||||
|
||||
### 良い点
|
||||
|
||||
**`LockFileGuard` の RAII 設計**: `open` で flock 取得、`save` で書き込み、Drop で unlock。mutate-but-don't-save のパスでは変更が破棄される(エラー時に安全)。
|
||||
|
||||
**`ScopeAllocationGuard` で Pod ライフサイクルに紐付け**: Pod 構造体が `Option<ScopeAllocationGuard>` を保持し、Drop で lock file から自動削除。`Pod::new` / `Pod::restore`(テスト用)は `None` でバイパス。
|
||||
|
||||
**`find_conflict_owner` が delegation tree を下降**: 衝突エラーに「真の所有者」(最深のノード)を表示。`conflict_detection_descends_to_real_owner` テストで lock-in。
|
||||
|
||||
**`reclaim_stale_with` のテストシーム**: PID 生存判定を引数で差し替え可能。テストで任意の PID を「dead」にできる。
|
||||
|
||||
**`is_within_effective_write`**: spawner の allow set から子に委譲済みの部分を差し引いた「実効 scope」を計算。`delegate_scope` のバリデーションに使用。
|
||||
|
||||
**`rules_overlap` の 4 パターン分岐** (recursive×recursive, recursive×non, non×recursive, non×non): 非再帰ルールの覆域(target 自身 + 直下の子)を正しく考慮。
|
||||
|
||||
### 指摘事項
|
||||
|
||||
#### 1. 🟡 ファイルパーミッションの明示設定
|
||||
|
||||
チケットの「セキュリティとアクセス」セクション:
|
||||
> ファイルパーミッション `0600`(owner only)、ディレクトリは `0700`
|
||||
|
||||
`LockFileGuard::open` は `OpenOptions::new().create(true)` で作成しているが、パーミッションの明示設定がない。umask がデフォルト (0022) なら `0644` になり、他ユーザーから読めてしまう。
|
||||
|
||||
```rust
|
||||
// 現状
|
||||
let file = OpenOptions::new()
|
||||
.read(true).write(true).create(true).truncate(false)
|
||||
.open(path)?;
|
||||
|
||||
// 修正案: Unix 拡張で mode を設定
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
let file = OpenOptions::new()
|
||||
.read(true).write(true).create(true).truncate(false)
|
||||
.mode(0o600)
|
||||
.open(path)?;
|
||||
```
|
||||
|
||||
ディレクトリも `create_dir_all` の後に `std::fs::set_permissions` で `0700` に制限する。
|
||||
|
||||
**判断**: セキュリティ要件。修正すべき。
|
||||
|
||||
#### 2. 🟢 `socket` フィールドの予測パス
|
||||
|
||||
`pod.rs` で socket path を `runtime_dir::default_base()?.join(&manifest.pod.name).join("sock")` と予測しているが、実際の `RuntimeDir` が作る socket path と一致するか保証がない(`RuntimeDir` のパス構築ロジックが変わったら乖離する)。
|
||||
|
||||
現時点では一致しているが、将来 `RuntimeDir` のパス規則が変わったとき scope_lock の socket 情報が嘘になる。`RuntimeDir` 側に `fn socket_path_for(pod_name: &str) -> PathBuf` のような static メソッドを置いて、両者が同じ関数を呼ぶようにするとより堅牢。
|
||||
|
||||
**判断**: 現時点では問題なし。リファクタ余地として認識。
|
||||
|
||||
#### 3. 🟢 `covers_fully` の permission 比較
|
||||
|
||||
```rust
|
||||
fn covers_fully(cover: &ScopeRule, inner: &ScopeRule) -> bool {
|
||||
if cover.permission < inner.permission {
|
||||
return false;
|
||||
}
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`Permission` の `Ord` は `Read < Write`。`cover.permission < inner.permission` = 「cover が Read で inner が Write なら不十分」。正しい。
|
||||
|
||||
#### 4. 🟢 テストの網羅性
|
||||
|
||||
16 ケース:
|
||||
- ファイル操作: open creates / save-reopen roundtrip
|
||||
- overlap 判定: prefix relation / non-recursive
|
||||
- 登録: write conflict / duplicate name / read doesn't conflict
|
||||
- 分譲: must be subset / succeeds within parent / rejects sibling overlap
|
||||
- 解放: reparents children / reopens scope / returns to parent
|
||||
- stale: reparents and removes dead entries
|
||||
- guard: releases on drop
|
||||
- conflict detection: descends to real owner
|
||||
|
||||
delegation tree の主要シナリオ (A→B→D) がカバーされている。
|
||||
|
||||
## 結論
|
||||
|
||||
**指摘1 (ファイルパーミッション 0600) の修正を条件に受け入れ可**。他は問題なし。実装の核心(delegation tree walk, effective scope 計算, stale reclaim + reparent)が正確で、テストが充実している。
|
||||
Loading…
Reference in New Issue
Block a user