scopeの再設計

This commit is contained in:
2026-04-14 12:09:18 +09:00
parent 3c58b5dde4
commit 2db2c1611c
24 changed files with 1074 additions and 318 deletions
+6 -1
View File
@@ -17,6 +17,7 @@ use session_store::FsStore;
const MANIFEST_TOML: &str = r#"
[pod]
name = "hello-pod"
pwd = "./"
[provider]
kind = "anthropic"
@@ -25,6 +26,10 @@ model = "claude-sonnet-4-20250514"
[worker]
system_prompt = "You are a concise assistant. Reply in one or two sentences."
max_tokens = 256
[[scope.allow]]
target = "./"
permission = "write"
"#;
#[tokio::main]
@@ -40,7 +45,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let store = FsStore::new(tmp.path()).await?;
// 3. Build the Pod from manifest
let mut pod = Pod::from_manifest(manifest, store, None, None).await?;
let mut pod = Pod::from_manifest(manifest, store, None).await?;
println!("Session: {}", pod.session_id());
// 4. Run a prompt
+6 -1
View File
@@ -11,6 +11,7 @@ use session_store::FsStore;
const MANIFEST_TOML: &str = r#"
[pod]
name = "protocol-demo"
pwd = "./"
[provider]
kind = "anthropic"
@@ -19,6 +20,10 @@ model = "claude-sonnet-4-20250514"
[worker]
system_prompt = "You are a concise assistant. Reply in one or two sentences."
max_tokens = 256
[[scope.allow]]
target = "./"
permission = "write"
"#;
#[tokio::main]
@@ -28,7 +33,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let manifest = PodManifest::from_toml(MANIFEST_TOML)?;
let tmp = tempfile::tempdir()?;
let store = FsStore::new(tmp.path()).await?;
let pod = pod::Pod::from_manifest(manifest, store, None, None).await?;
let pod = pod::Pod::from_manifest(manifest, store, None).await?;
let runtime_tmp = tempfile::tempdir()?;
let handle = PodController::spawn(pod, runtime_tmp.path()).await?;
+15 -18
View File
@@ -83,9 +83,10 @@ impl PodController {
// Keep the server alive by moving it into the controller task
// (it will be dropped when the task ends)
// Grab the scope before the mutable borrow of the worker so we can
// build a `ScopedFs` for the builtin tools. `Scope` is cheap to clone.
let scope_for_tools = pod.scope().cloned();
// Grab the scope/pwd before the mutable borrow of the worker so we
// can build a `ScopedFs` for the builtin tools.
let scope_for_tools = pod.scope().clone();
let pwd_for_tools = pod.pwd().to_path_buf();
// Register event bridge callbacks on the worker
{
@@ -161,21 +162,17 @@ impl PodController {
});
// Register the builtin file-manipulation tools (Read / Write /
// Edit / Glob / Grep) when the manifest declares a scope.
//
// `ScopedFs` carries the pod-lifetime write boundary (derived
// from the manifest scope). `Tracker` is session-scoped —
// a fresh instance per controller spawn ensures state from a
// previous process lifetime cannot be reused after a resume.
// The tracker is also handed to the Pod itself so Pod-level
// operations (e.g. context compaction) can ask which files
// the agent has been touching.
if let Some(scope) = scope_for_tools {
let fs = tools::ScopedFs::new(scope);
let tracker = tools::Tracker::new();
worker.register_tools(tools::builtin_tools(fs, tracker.clone()));
pod.attach_tracker(tracker);
}
// Edit / Glob / Grep). `ScopedFs` carries the pod-lifetime
// scope/pwd; `Tracker` is session-scoped — a fresh instance per
// controller spawn ensures state from a previous process
// lifetime cannot be reused after a resume. The tracker is
// also handed to the Pod itself so Pod-level operations (e.g.
// context compaction) can ask which files the agent has been
// touching.
let fs = tools::ScopedFs::new(scope_for_tools, pwd_for_tools);
let tracker = tools::Tracker::new();
worker.register_tools(tools::builtin_tools(fs, tracker.clone()));
pod.attach_tracker(tracker);
}
// Clone cancel sender before moving pod
+2 -14
View File
@@ -70,23 +70,11 @@ async fn main() -> ExitCode {
}
};
// Build scope from manifest
let scope = match manifest.scope.as_ref() {
Some(sc) => match manifest::Scope::new(&sc.root) {
Ok(s) => Some(s),
Err(e) => {
eprintln!("error: invalid scope root {:?}: {e}", sc.root);
return ExitCode::FAILURE;
}
},
None => None,
};
// Build the Pod
// Build the Pod (pwd/scope derived from manifest + manifest_dir).
let manifest_dir = std::fs::canonicalize(&cli.manifest)
.ok()
.and_then(|p| p.parent().map(Path::to_path_buf));
let pod = match Pod::from_manifest(manifest, store, scope, manifest_dir).await {
let pod = match Pod::from_manifest(manifest, store, manifest_dir).await {
Ok(p) => p,
Err(e) => {
eprintln!("error: failed to create pod: {e}");
+68 -11
View File
@@ -1,4 +1,4 @@
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use llm_worker::Item;
@@ -11,7 +11,7 @@ use session_store::{
};
use tracing::{info, warn};
use manifest::{PodManifest, Scope, WorkerManifest};
use manifest::{PodManifest, Scope, ScopeError, WorkerManifest};
use crate::compact_interceptor::CompactInterceptor;
use crate::compact_state::CompactState;
@@ -64,7 +64,10 @@ pub struct Pod<C: LlmClient, St: Store> {
store: St,
session_id: SessionId,
head_hash: Option<EntryHash>,
scope: Option<Scope>,
/// Absolute working directory of the Pod.
pwd: PathBuf,
/// Resolved scope — always present.
scope: Scope,
hook_builder: HookRegistryBuilder,
interceptor_installed: bool,
/// Directory containing the manifest file (needed for api_key_file resolution).
@@ -92,11 +95,16 @@ pub struct Pod<C: LlmClient, St: Store> {
impl<C: LlmClient, St: Store> Pod<C, St> {
/// Create a new Pod from a pre-built Worker and store.
///
/// Callers must pre-resolve `pwd` (absolute) and build a [`Scope`]
/// — typically via [`Scope::from_config`] when coming from a
/// manifest, or [`Scope::writable`] in tests.
pub async fn new(
manifest: PodManifest,
worker: Worker<C>,
store: St,
scope: Option<Scope>,
pwd: PathBuf,
scope: Scope,
) -> Result<Self, PodError> {
let state = SessionStartState {
system_prompt: worker.get_system_prompt(),
@@ -110,6 +118,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
store,
session_id,
head_hash: Some(head_hash),
pwd,
scope,
hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false,
@@ -129,7 +138,8 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
manifest: PodManifest,
client: C,
store: St,
scope: Option<Scope>,
pwd: PathBuf,
scope: Scope,
) -> Result<Self, PodError> {
let state = session_store::restore(&store, session_id).await?;
let mut worker = Worker::new(client);
@@ -147,6 +157,7 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
store,
session_id,
head_hash: state.head_hash,
pwd,
scope,
hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false,
@@ -170,9 +181,14 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
&self.manifest
}
/// The Pod's directory scope, if any.
pub fn scope(&self) -> Option<&Scope> {
self.scope.as_ref()
/// The Pod's working directory.
pub fn pwd(&self) -> &Path {
&self.pwd
}
/// The Pod's directory scope.
pub fn scope(&self) -> &Scope {
&self.scope
}
/// Direct access to the underlying Worker.
@@ -689,12 +705,22 @@ impl<C: LlmClient, St: Store> Pod<C, St> {
impl<St: Store> Pod<Box<dyn LlmClient>, St> {
/// Create a Pod entirely from a manifest.
///
/// Resolves `manifest.pod.pwd` against `manifest_dir` (or the
/// current working directory when absent), builds the [`Scope`]
/// from `manifest.scope`, and validates that the resolved pwd is
/// readable under that scope.
pub async fn from_manifest(
manifest: PodManifest,
store: St,
scope: Option<Scope>,
manifest_dir: Option<PathBuf>,
) -> Result<Self, PodError> {
let pwd = resolve_pwd(&manifest.pod.pwd, manifest_dir.as_deref())?;
let scope = Scope::from_config(&manifest.scope, &pwd).map_err(PodError::Scope)?;
if !scope.is_readable(&pwd) {
return Err(PodError::PwdOutsideScope { pwd });
}
let client = provider::build_client(&manifest.provider, manifest_dir.as_deref())?;
let mut worker = Worker::new(client);
apply_worker_manifest(&mut worker, &manifest.worker);
@@ -711,6 +737,7 @@ impl<St: Store> Pod<Box<dyn LlmClient>, St> {
store,
session_id,
head_hash: Some(head_hash),
pwd,
scope,
hook_builder: HookRegistryBuilder::new(),
interceptor_installed: false,
@@ -811,8 +838,18 @@ pub enum PodError {
#[error(transparent)]
Store(#[from] StoreError),
#[error("scope violation: {path} is outside the allowed directory")]
ScopeViolation { path: String },
#[error(transparent)]
Scope(ScopeError),
#[error("pwd is not readable under the configured scope: {}", .pwd.display())]
PwdOutsideScope { pwd: PathBuf },
#[error("failed to resolve pwd {}: {source}", .pwd.display())]
InvalidPwd {
pwd: PathBuf,
#[source]
source: std::io::Error,
},
#[error(transparent)]
Provider(#[from] provider::ProviderError),
@@ -820,3 +857,23 @@ pub enum PodError {
#[error("compaction thrash: context still exceeds threshold immediately after compact")]
CompactThrash,
}
/// Resolve the pwd declared in a manifest against `manifest_dir` (or the
/// current working directory when absent), canonicalizing symlinks.
fn resolve_pwd(pwd: &Path, manifest_dir: Option<&Path>) -> Result<PathBuf, PodError> {
let joined = if pwd.is_absolute() {
pwd.to_path_buf()
} else {
let base = manifest_dir
.map(Path::to_path_buf)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."));
base.join(pwd)
};
joined
.canonicalize()
.map_err(|source| PodError::InvalidPwd {
pwd: joined,
source,
})
}
+18 -5
View File
@@ -74,6 +74,7 @@ fn simple_text_events() -> Vec<LlmEvent> {
const MANIFEST_TOML: &str = r#"
[pod]
name = "test-pod"
pwd = "./"
[provider]
kind = "anthropic"
@@ -81,16 +82,28 @@ model = "test-model"
[worker]
max_tokens = 100
[[scope.allow]]
target = "./"
permission = "write"
"#;
async fn make_pod(client: MockClient) -> Pod<MockClient, FsStore> {
let manifest = PodManifest::from_toml(MANIFEST_TOML).unwrap();
let tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(tmp.path()).await.unwrap();
// Leak tempdir to keep it alive
std::mem::forget(tmp);
let store_tmp = tempfile::tempdir().unwrap();
let store = FsStore::new(store_tmp.path()).await.unwrap();
std::mem::forget(store_tmp);
// Separate tempdir to serve as the Pod's pwd/scope — these tests
// exercise the controller via a mock client and never touch the
// filesystem through tools, so a throwaway writable dir is enough.
let pwd_tmp = tempfile::tempdir().unwrap();
let pwd = pwd_tmp.path().to_path_buf();
let scope = manifest::Scope::writable(&pwd).unwrap();
std::mem::forget(pwd_tmp);
let worker = Worker::new(client);
Pod::new(manifest, worker, store, None).await.unwrap()
Pod::new(manifest, worker, store, pwd, scope).await.unwrap()
}
use pod::PodHandle;