scopeの再設計
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user