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",
|
"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]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
|
|
@ -1388,9 +1398,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libc"
|
name = "libc"
|
||||||
version = "0.2.184"
|
version = "0.2.185"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af"
|
checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libredox"
|
name = "libredox"
|
||||||
|
|
@ -1893,8 +1903,10 @@ dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"dotenv",
|
"dotenv",
|
||||||
|
"fs4",
|
||||||
"futures",
|
"futures",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
|
"libc",
|
||||||
"llm-worker",
|
"llm-worker",
|
||||||
"manifest",
|
"manifest",
|
||||||
"minijinja",
|
"minijinja",
|
||||||
|
|
@ -2244,7 +2256,7 @@ dependencies = [
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.4.15",
|
"linux-raw-sys 0.4.15",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3583,6 +3595,15 @@ dependencies = [
|
||||||
"windows-targets",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.61.2"
|
version = "0.61.2"
|
||||||
|
|
|
||||||
|
|
@ -138,6 +138,22 @@ impl Scope {
|
||||||
self.allow.iter().map(|r| r.target.as_path())
|
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.
|
/// Iterate over absolute paths granted `Write` by an allow rule.
|
||||||
/// Subset of [`readable_paths`](Self::readable_paths).
|
/// Subset of [`readable_paths`](Self::readable_paths).
|
||||||
pub fn writable_paths(&self) -> impl Iterator<Item = &Path> {
|
pub fn writable_paths(&self) -> impl Iterator<Item = &Path> {
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ tools = { version = "0.1.0", path = "../tools" }
|
||||||
minijinja = "2.19.0"
|
minijinja = "2.19.0"
|
||||||
chrono = "0.4.44"
|
chrono = "0.4.44"
|
||||||
include_dir = "0.7.4"
|
include_dir = "0.7.4"
|
||||||
|
fs4 = { version = "0.13.1", features = ["sync"] }
|
||||||
|
libc = "0.2.185"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ pub mod controller;
|
||||||
pub mod hook;
|
pub mod hook;
|
||||||
pub mod notifier;
|
pub mod notifier;
|
||||||
pub mod runtime_dir;
|
pub mod runtime_dir;
|
||||||
|
pub mod scope_lock;
|
||||||
pub mod shared_state;
|
pub mod shared_state;
|
||||||
pub mod socket_server;
|
pub mod socket_server;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ use crate::notification_buffer::NotificationBuffer;
|
||||||
use crate::notifier::Notifier;
|
use crate::notifier::Notifier;
|
||||||
use crate::pod_interceptor::PodInterceptor;
|
use crate::pod_interceptor::PodInterceptor;
|
||||||
use crate::prompt_loader::PromptLoader;
|
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::system_prompt::{SystemPromptContext, SystemPromptError, SystemPromptTemplate};
|
||||||
use crate::usage_tracker::UsageTracker;
|
use crate::usage_tracker::UsageTracker;
|
||||||
use protocol::{NotificationLevel, NotificationSource};
|
use protocol::{NotificationLevel, NotificationSource};
|
||||||
|
|
@ -105,6 +107,13 @@ pub struct Pod<C: LlmClient, St: Store> {
|
||||||
/// injection into the next LLM request. Shared with the
|
/// injection into the next LLM request. Shared with the
|
||||||
/// PodInterceptor installed in `ensure_interceptor_installed`.
|
/// PodInterceptor installed in `ensure_interceptor_installed`.
|
||||||
pending_notifications: NotificationBuffer,
|
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> {
|
impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
|
|
@ -146,6 +155,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
notifier: None,
|
notifier: None,
|
||||||
pending_notifications: NotificationBuffer::new(),
|
pending_notifications: NotificationBuffer::new(),
|
||||||
|
scope_allocation: None,
|
||||||
};
|
};
|
||||||
pod.apply_prune_from_manifest();
|
pod.apply_prune_from_manifest();
|
||||||
Ok(pod)
|
Ok(pod)
|
||||||
|
|
@ -195,6 +205,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
|
||||||
system_prompt_template: None,
|
system_prompt_template: None,
|
||||||
notifier: None,
|
notifier: None,
|
||||||
pending_notifications: NotificationBuffer::new(),
|
pending_notifications: NotificationBuffer::new(),
|
||||||
|
scope_allocation: None,
|
||||||
};
|
};
|
||||||
pod.apply_prune_from_manifest();
|
pod.apply_prune_from_manifest();
|
||||||
Ok(pod)
|
Ok(pod)
|
||||||
|
|
@ -875,6 +886,20 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
return Err(PodError::PwdOutsideScope { pwd });
|
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 client = provider::build_client(&manifest.provider)?;
|
||||||
let mut worker = Worker::new(client);
|
let mut worker = Worker::new(client);
|
||||||
apply_worker_manifest(&mut worker, &manifest.worker);
|
apply_worker_manifest(&mut worker, &manifest.worker);
|
||||||
|
|
@ -910,6 +935,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
|
||||||
system_prompt_template,
|
system_prompt_template,
|
||||||
notifier: None,
|
notifier: None,
|
||||||
pending_notifications: NotificationBuffer::new(),
|
pending_notifications: NotificationBuffer::new(),
|
||||||
|
scope_allocation: Some(scope_allocation),
|
||||||
};
|
};
|
||||||
pod.apply_prune_from_manifest();
|
pod.apply_prune_from_manifest();
|
||||||
Ok(pod)
|
Ok(pod)
|
||||||
|
|
@ -1058,6 +1084,9 @@ pub enum PodError {
|
||||||
#[source]
|
#[source]
|
||||||
source: SystemPromptError,
|
source: SystemPromptError,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
#[error(transparent)]
|
||||||
|
ScopeLock(#[from] ScopeLockError),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Canonicalize an absolute pwd (resolves symlinks and any `.`/`..`
|
/// 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.
|
/// 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") {
|
if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
|
||||||
Ok(PathBuf::from(runtime_dir).join("insomnia"))
|
Ok(PathBuf::from(runtime_dir).join("insomnia"))
|
||||||
} else if let Ok(home) = std::env::var("HOME") {
|
} 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 file: write 排他とスコープ分譲の記録基盤
|
||||||
|
|
||||||
|
## レビュー状態
|
||||||
|
|
||||||
|
初回レビュー実施済み。[scope-lock.review.md](scope-lock.review.md) を参照。
|
||||||
|
指摘1件(ファイルパーミッション 0600 の明示設定)の修正を条件に受け入れ可。
|
||||||
|
|
||||||
## 背景
|
## 背景
|
||||||
|
|
||||||
Pod オーケストレーションでは scope の分譲(spawner が自身の scope を spawned Pod に譲渡)が発生する。また、人間が独立に複数の Pod を起動した場合にも同一パスへの write 衝突を検出する必要がある。
|
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