scope-lock完了

This commit is contained in:
Keisuke Hirata 2026-04-18 19:26:23 +09:00
parent a7b9b6fa4b
commit e7a4b76c54
4 changed files with 6 additions and 202 deletions

View File

@ -5,7 +5,6 @@
- [ ] Protocol の設計 → [tickets/protocol-design.md](tickets/protocol-design.md)
- [ ] パーミッション: パターンベースのツール実行制御 → [tickets/permission-extension-point.md](tickets/permission-extension-point.md)
- [ ] Pod オーケストレーション
- [ ] Scope lock file: write 排他とスコープ分譲の記録基盤 → [tickets/scope-lock.md](tickets/scope-lock.md)
- [ ] SpawnPod ツール: LLM から Pod を生成 → [tickets/spawn-pod-tool.md](tickets/spawn-pod-tool.md)
- [ ] Pod 間通信ツール: SendToPod / ReadPodOutput / StopPod / ListPods → [tickets/pod-comm-tools.md](tickets/pod-comm-tools.md)
- [ ] Pod 間コールバック通知 → [tickets/pod-callback.md](tickets/pod-callback.md)

View File

@ -13,9 +13,9 @@
//! recovery rides on the next Pod that opens the file — no background
//! reaper.
use std::fs::{File, OpenOptions};
use std::fs::{DirBuilder, File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
use fs4::fs_std::FileExt;
@ -109,11 +109,10 @@ impl LockFileGuard {
/// allocation table. Existing files/directories are left alone.
pub fn open(path: &Path) -> io::Result<Self> {
if let Some(parent) = path.parent() {
let existed = parent.exists();
std::fs::create_dir_all(parent)?;
if !existed {
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
}
DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(parent)?;
}
let file = OpenOptions::new()
.read(true)

View File

@ -1,94 +0,0 @@
# Scope lock file: write 排他とスコープ分譲の記録基盤
## レビュー状態
初回レビュー実施済み。[scope-lock.review.md](scope-lock.review.md) を参照。
指摘1件ファイルパーミッション 0600 の明示設定)の修正を条件に受け入れ可。
## 背景
Pod オーケストレーションでは scope の分譲spawner が自身の scope を spawned Pod に譲渡)が発生する。また、人間が独立に複数の Pod を起動した場合にも同一パスへの write 衝突を検出する必要がある。
これらを解決するため、マシン上の全 Pod の scope 割り当てを**単一の lock file**で一元管理する。
## 仕様
### lock file
置き場: `$XDG_RUNTIME_DIR/insomnia/scope.lock`
内容:
```json
{
"allocations": [
{
"name": "abc123",
"pid": 12345,
"socket": "/run/insomnia/.../pod-a.sock",
"scope_allow": ["/project/src:write:recursive"],
"delegated_from": null
},
{
"name": "def456",
"pid": 12346,
"socket": "/run/insomnia/.../pod-b.sock",
"scope_allow": ["/project/src/core:write:recursive"],
"delegated_from": "abc123"
}
]
}
```
アクセスは `flock(2)` による advisory lock で排他制御する。
### 操作
| タイミング | 動作 |
|---|---|
| **Pod 起動** | lock → stale 検出PID 死活)→ 自動回収 → write 衝突チェック → 自分の scope を登録 → unlock |
| **scope 分譲** | lock → spawner の allocation に deny 追記 → 新 Pod の allocation を追加(`delegated_from` に spawner→ unlock |
| **Pod 正常終了** | lock → 自分の allocation を削除 → `delegated_from` が自分の子が残っていなければ親の deny を解除 → unlock |
| **stale 検出** | `kill(pid, 0)` で生存確認。死んでいたら allocation を削除し scope を `delegated_from` の親に返却 |
### stale の自動回収
Pod がクラッシュした場合、lock file にエントリが残る。次に lock file を開いた Pod が stale を検出し自動回収する:
- 死亡 Pod の scope のうち、生存中の子 Pod が持つ分を除外
- 残りを `delegated_from` の親に返却
- 死亡 Pod のエントリを削除
- 子 Pod の `delegated_from` を親に付け替え
### effective scope の導出
```
effective_scope = 自分の allocation - Σ(delegated_from が自分を指す子の allocation)
```
### セキュリティとアクセス
- ファイルパーミッション `0600`owner only、ディレクトリは `0700`。他ユーザーからの読み取りを防ぐ
- ownerPod を動かしているユーザーは当然読める。JSON なので直接確認も可能
- Pod による lock file 探索は排他制御の目的に限定する。Pod 発見のための lock file スキャンは行わないPod の発見は spawn 記録 + 明示的な紹介のみ)
- 衝突で Pod 起動が拒否されたとき、競合相手の name をエラーメッセージに含める
## 実装
- 新規モジュール `crates/pod/src/scope_lock.rs`(または `crates/scope-lock/`
- Pod 起動時(`Pod::from_manifest` / `Pod::from_manifest_toml`)に lock 取得
- Pod 終了時(`Drop` または明示的 releaseに lock 解放
- Controller 層でのエラー伝搬
## 完了条件
- Pod 起動時に scope lock file に allocation が記録される
- 同一パスへの write 衝突が検出され、Pod 起動が拒否される(競合相手の name がエラーに含まれる)
- Pod 正常終了時に allocation が削除される
- stale エントリPID 死亡が自動回収され、scope が親に戻る
- 分譲チェーンA→B→Dの部分回収が正しく動作する
- 単体テストで衝突検出・stale 回収・分譲/返却が検証される
## 範囲外
- SpawnPod ツール自体の実装(`tickets/spawn-pod-tool.md`
- scope の分譲粒度permission レベルでの分譲等)は当面パス単位のみ

View File

@ -1,100 +0,0 @@
# レビュー: 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が正確で、テストが充実している。