Skip to main content

bamboo_engine/
project_context.rs

1//! Stable Project identity and shared-resource resolution.
2//!
3//! A Project is not a workspace.  The Project id carried by a session is the
4//! authority for memory and shared resources; the workspace is only the
5//! mutable filesystem execution context.  This module is the single engine
6//! seam for resolving those two identities together.
7
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use bamboo_agent_core::Session;
13use bamboo_domain::{ProjectId, ProjectResourceKind, ProjectResourceSummary, WorkspaceBinding};
14use serde::{Deserialize, Serialize};
15
16pub const PROJECT_ID_METADATA_KEY: &str = "project_id";
17pub const PROJECT_RESOURCES_RENDERED_KEY: &str = "project_resources_rendered";
18pub const WORKSPACE_SOURCE_METADATA_KEY: &str = "workspace_source";
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum WorkspaceBindingStatus {
23    Registered,
24    Unregistered,
25}
26
27impl WorkspaceBindingStatus {
28    pub const fn as_str(self) -> &'static str {
29        match self {
30            Self::Registered => "registered",
31            Self::Unregistered => "unregistered",
32        }
33    }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ProjectDescriptor {
38    pub id: ProjectId,
39    pub name: String,
40    /// Canonical user source folder and default execution workspace.
41    pub project_path: Option<PathBuf>,
42    /// Bamboo-owned Project data/resources directory.
43    pub home: PathBuf,
44    pub workspace_bindings: Vec<WorkspaceBinding>,
45    pub resources: ProjectResourceSummary,
46    pub memory_read_roots: ProjectMemoryReadRoots,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct ProjectMemoryReadRoots {
51    pub primary: PathBuf,
52    pub legacy_aliases: Vec<bamboo_memory::memory_store::LegacyProjectMemoryReadRoot>,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub struct ResolvedProjectContext {
57    pub project: ProjectDescriptor,
58    pub workspace: Option<PathBuf>,
59    pub workspace_source: WorkspaceSource,
60    pub binding_status: WorkspaceBindingStatus,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum WorkspaceSource {
66    Explicit,
67    Session,
68    ProjectDefault,
69}
70
71impl WorkspaceSource {
72    pub const fn as_str(self) -> &'static str {
73        match self {
74            Self::Explicit => "explicit",
75            Self::Session => "session",
76            Self::ProjectDefault => "project_default",
77        }
78    }
79}
80
81impl ResolvedProjectContext {
82    pub fn resource_scope(&self) -> ProjectResourceScope {
83        ProjectResourceScope {
84            project_id: self.project.id.clone(),
85            project_home: self.project.home.clone(),
86            workspace: self.workspace.clone(),
87            binding_status: self.binding_status,
88            resource_revision: self.project.resources.resource_revision,
89        }
90    }
91
92    pub fn render_resource_inventory(&self) -> String {
93        let mut entries = self.project.resources.resources.clone();
94        entries.sort_by_key(|entry| entry.kind);
95        let rendered = entries
96            .into_iter()
97            .map(|entry| {
98                format!(
99                    "- {:?}: status={}, items={}",
100                    entry.kind,
101                    if entry.present { "available" } else { "empty" },
102                    entry.item_count
103                )
104            })
105            .collect::<Vec<_>>()
106            .join("\n");
107        format!(
108            "Project ID: {}\nResource revision: {}\n{}",
109            self.project.id,
110            self.project.resources.resource_revision,
111            if rendered.is_empty() {
112                "No Project-shared resources are currently advertised.".to_string()
113            } else {
114                rendered
115            }
116        )
117    }
118}
119
120#[derive(Debug, thiserror::Error)]
121pub enum ProjectContextError {
122    #[error("project context source failed: {0}")]
123    Source(String),
124    #[error("project context source returned '{actual}' for requested project '{requested}'")]
125    IdentityMismatch { requested: String, actual: String },
126    #[error(
127        "workspace '{workspace}' belongs to Project '{owner_project_id}', not session Project '{session_project_id}'"
128    )]
129    WorkspaceConflict {
130        workspace: String,
131        owner_project_id: ProjectId,
132        session_project_id: ProjectId,
133    },
134    #[error("workspace '{workspace}' belongs to Project '{owner_project_id}', but the session is Unassigned")]
135    UnassignedWorkspaceConflict {
136        workspace: String,
137        owner_project_id: ProjectId,
138    },
139    #[error("session carries an invalid Project identity '{raw}': {message}")]
140    InvalidProjectIdentity { raw: String, message: String },
141    #[error("assigned Project '{project_id}' is unavailable")]
142    ProjectUnavailable { project_id: ProjectId },
143    #[error("assigned Project '{project_id}' has no configured project_path")]
144    ProjectPathMissing { project_id: ProjectId },
145    #[error("assigned Project '{project_id}' path '{project_path}' is unavailable: {message}")]
146    ProjectPathUnavailable {
147        project_id: ProjectId,
148        project_path: String,
149        message: String,
150    },
151    #[error("workspace '{workspace}' is invalid: {message}")]
152    WorkspaceInvalid { workspace: String, message: String },
153}
154
155/// Adapter implemented by the authoritative Project registry.
156///
157/// The engine deliberately depends on this redacted descriptor rather than on
158/// registry persistence details. Secret settings and credential values must
159/// never be added to this interface.
160#[async_trait]
161pub trait ProjectContextSource: Send + Sync {
162    async fn find_project(
163        &self,
164        project_id: &ProjectId,
165    ) -> Result<Option<ProjectDescriptor>, ProjectContextError>;
166
167    async fn list_projects(&self) -> Result<Vec<ProjectDescriptor>, ProjectContextError> {
168        Ok(Vec::new())
169    }
170
171    /// Resolve the global Project owner for an exact workspace. Sources that
172    /// cannot provide a registry-wide answer remain backward compatible.
173    async fn find_workspace_owner(
174        &self,
175        _workspace: &Path,
176    ) -> Result<Option<ProjectId>, ProjectContextError> {
177        Ok(None)
178    }
179}
180
181#[derive(Clone)]
182pub struct ProjectContextResolver {
183    source: Arc<dyn ProjectContextSource>,
184    workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum SessionProjectIdentity {
189    Unassigned,
190    Assigned(ProjectId),
191    Invalid { raw: String, message: String },
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub enum ProjectMemoryScope {
196    Assigned {
197        project_id: ProjectId,
198        legacy_aliases: Vec<bamboo_memory::memory_store::LegacyProjectMemoryReadRoot>,
199    },
200    LegacyReadOnly(String),
201}
202
203impl ProjectMemoryScope {
204    pub fn key(&self) -> &str {
205        match self {
206            Self::Assigned { project_id, .. } => project_id.as_str(),
207            Self::LegacyReadOnly(project_key) => project_key,
208        }
209    }
210
211    pub fn scoped_store(
212        &self,
213        store: &bamboo_memory::memory_store::MemoryStore,
214    ) -> bamboo_memory::memory_store::MemoryStore {
215        match self {
216            Self::Assigned {
217                project_id,
218                legacy_aliases,
219            } => store.for_project_with_legacy_read_roots(project_id, legacy_aliases.clone()),
220            Self::LegacyReadOnly(_) => store.clone(),
221        }
222    }
223}
224
225impl ProjectContextResolver {
226    pub fn new(source: Arc<dyn ProjectContextSource>) -> Self {
227        Self {
228            source,
229            workspace_resolver:
230                bamboo_agent_core::workspace_state::WorkspaceResolver::from_process_globals(),
231        }
232    }
233
234    /// Build a Project resolver against one coherent workspace-provider pair.
235    ///
236    /// The server uses this form so preview/ownership validation cannot observe
237    /// a different `AppState`'s process-global first-wins provider. Other
238    /// embeddings retain [`Self::new`]'s dynamic global behavior.
239    pub fn new_with_workspace_resolver(
240        source: Arc<dyn ProjectContextSource>,
241        workspace_resolver: bamboo_agent_core::workspace_state::WorkspaceResolver,
242    ) -> Self {
243        Self {
244            source,
245            workspace_resolver,
246        }
247    }
248
249    /// Return the stable, opaque Project id persisted on the session.
250    ///
251    /// The domain accessor dual-writes this compatibility key. Keeping the
252    /// read centralized here prevents memory, Dream, prompt, and resource
253    /// callers from independently falling back to mutable workspace identity.
254    pub fn project_id_from_session(session: &Session) -> Option<ProjectId> {
255        match Self::session_project_identity(session) {
256            SessionProjectIdentity::Assigned(project_id) => Some(project_id),
257            SessionProjectIdentity::Invalid { raw, message } => {
258                tracing::warn!(
259                    session_id = %session.id,
260                    "ignoring invalid persisted Project id '{raw}': {message}"
261                );
262                None
263            }
264            SessionProjectIdentity::Unassigned => None,
265        }
266    }
267
268    /// Parse persisted Project membership into an authoritative three-state
269    /// value. Whitespace is normalized exactly like the rebuildable storage
270    /// index. Callers with security/resource consequences must distinguish
271    /// `Invalid` from truly `Unassigned`.
272    pub fn session_project_identity(session: &Session) -> SessionProjectIdentity {
273        let Some(raw) = session.project_id_meta() else {
274            return SessionProjectIdentity::Unassigned;
275        };
276        let normalized = raw.trim();
277        match ProjectId::parse(normalized) {
278            Ok(project_id) => SessionProjectIdentity::Assigned(project_id),
279            Err(error) => SessionProjectIdentity::Invalid {
280                raw,
281                message: error.to_string(),
282            },
283        }
284    }
285
286    /// Resolve the Project id used for memory reads.
287    ///
288    /// Assigned sessions always use their stable Project id. The path-derived
289    /// fallback is read-compatibility for unassigned legacy sessions only; new
290    /// sessions and writes must use [`Self::project_id_from_session`].
291    pub fn memory_read_scope_for_session(session: &Session) -> Option<String> {
292        Self::memory_read_identity_for_session(session).map(|scope| scope.key().to_string())
293    }
294
295    pub fn memory_read_identity_for_session(session: &Session) -> Option<ProjectMemoryScope> {
296        match Self::session_project_identity(session) {
297            SessionProjectIdentity::Assigned(project_id) => Some(ProjectMemoryScope::Assigned {
298                project_id,
299                legacy_aliases: Vec::new(),
300            }),
301            SessionProjectIdentity::Unassigned => session
302                .workspace_path_meta()
303                .map(PathBuf::from)
304                .or_else(|| {
305                    bamboo_tools::tools::workspace_state::get_workspace(session.id.as_str())
306                })
307                .map(|path| {
308                    ProjectMemoryScope::LegacyReadOnly(
309                        bamboo_memory::memory_store::project_key_from_path(&path),
310                    )
311                }),
312            SessionProjectIdentity::Invalid { .. } => None,
313        }
314    }
315
316    pub async fn resolve_memory_read_scope(
317        &self,
318        session: &Session,
319        workspace: Option<&Path>,
320    ) -> Result<Option<ProjectMemoryScope>, ProjectContextError> {
321        match Self::session_project_identity(session) {
322            SessionProjectIdentity::Unassigned => {
323                return Ok(Self::memory_read_identity_for_session(session));
324            }
325            SessionProjectIdentity::Invalid { raw, message } => {
326                return Err(ProjectContextError::InvalidProjectIdentity { raw, message });
327            }
328            SessionProjectIdentity::Assigned(_) => {}
329        }
330        Ok(self
331            .resolve(session, workspace)
332            .await?
333            .map(|context| ProjectMemoryScope::Assigned {
334                project_id: context.project.id,
335                legacy_aliases: context.project.memory_read_roots.legacy_aliases,
336            }))
337    }
338
339    pub async fn list_memory_read_scopes(
340        &self,
341    ) -> Result<Vec<ProjectMemoryScope>, ProjectContextError> {
342        Ok(self
343            .source
344            .list_projects()
345            .await?
346            .into_iter()
347            .map(|project| ProjectMemoryScope::Assigned {
348                project_id: project.id,
349                legacy_aliases: project.memory_read_roots.legacy_aliases,
350            })
351            .collect())
352    }
353
354    /// Resolve the only valid Project write scope.
355    ///
356    /// Unassigned legacy sessions intentionally return `None`: their
357    /// path-derived scopes are read/migration aliases and must never receive
358    /// new Project memory or Dream writes.
359    pub fn memory_write_scope_for_session(session: &Session) -> Option<String> {
360        match Self::session_project_identity(session) {
361            SessionProjectIdentity::Assigned(project_id) => Some(project_id.into_string()),
362            SessionProjectIdentity::Unassigned | SessionProjectIdentity::Invalid { .. } => None,
363        }
364    }
365
366    pub async fn resolve(
367        &self,
368        session: &Session,
369        workspace: Option<&Path>,
370    ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
371        let project_id = match Self::session_project_identity(session) {
372            SessionProjectIdentity::Assigned(project_id) => project_id,
373            SessionProjectIdentity::Invalid { raw, message } => {
374                return Err(ProjectContextError::InvalidProjectIdentity { raw, message });
375            }
376            SessionProjectIdentity::Unassigned => {
377                let workspace =
378                    self.resolve_workspace_candidate_for_instance(session, workspace)?;
379                if let Some(candidate) = workspace.as_deref() {
380                    if let Some(owner_project_id) =
381                        self.source.find_workspace_owner(candidate).await?
382                    {
383                        return Err(ProjectContextError::UnassignedWorkspaceConflict {
384                            workspace: candidate.to_string_lossy().into_owned(),
385                            owner_project_id,
386                        });
387                    }
388                }
389                return Ok(None);
390            }
391        };
392        let project = self
393            .source
394            .find_project(&project_id)
395            .await?
396            .ok_or_else(|| ProjectContextError::ProjectUnavailable {
397                project_id: project_id.clone(),
398            })?;
399        if project.id != project_id {
400            return Err(ProjectContextError::IdentityMismatch {
401                requested: project_id.to_string(),
402                actual: project.id.to_string(),
403            });
404        }
405
406        let (workspace, workspace_source) = if let Some(workspace) = workspace {
407            (
408                resolve_existing_workspace_with_resolver(workspace, &self.workspace_resolver)?,
409                WorkspaceSource::Explicit,
410            )
411        } else if session
412            .metadata
413            .get(WORKSPACE_SOURCE_METADATA_KEY)
414            .map(String::as_str)
415            == Some(WorkspaceSource::ProjectDefault.as_str())
416        {
417            (
418                resolve_project_default_workspace(&project, &self.workspace_resolver)?,
419                WorkspaceSource::ProjectDefault,
420            )
421        } else if let Some(workspace) = session.workspace_path_meta() {
422            let persisted_source = session
423                .metadata
424                .get(WORKSPACE_SOURCE_METADATA_KEY)
425                .map(String::as_str);
426            let workspace = resolve_existing_workspace_with_resolver(
427                Path::new(&workspace),
428                &self.workspace_resolver,
429            )?;
430            let source = match persisted_source {
431                Some("explicit") => WorkspaceSource::Explicit,
432                Some("session") => WorkspaceSource::Session,
433                _ => WorkspaceSource::Session,
434            };
435            (workspace, source)
436        } else {
437            (
438                resolve_project_default_workspace(&project, &self.workspace_resolver)?,
439                WorkspaceSource::ProjectDefault,
440            )
441        };
442
443        self.resolve_assigned_project(project, workspace, workspace_source)
444            .await
445            .map(Some)
446    }
447
448    /// Resolve the exact workspace the runtime will use without publishing it.
449    ///
450    /// This is shared by HTTP preflight, SDK/execute prompt refresh, and the
451    /// Workspace tool so configured/session-default fallbacks cannot bypass
452    /// Project ownership checks.
453    pub fn resolve_workspace_candidate(
454        session: &Session,
455        workspace: Option<&Path>,
456    ) -> Result<Option<PathBuf>, ProjectContextError> {
457        Self::resolve_workspace_candidate_with(
458            &bamboo_agent_core::workspace_state::WorkspaceResolver::from_process_globals(),
459            session,
460            workspace,
461        )
462    }
463
464    fn resolve_workspace_candidate_for_instance(
465        &self,
466        session: &Session,
467        workspace: Option<&Path>,
468    ) -> Result<Option<PathBuf>, ProjectContextError> {
469        Self::resolve_workspace_candidate_with(&self.workspace_resolver, session, workspace)
470    }
471
472    fn resolve_workspace_candidate_with(
473        workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
474        session: &Session,
475        workspace: Option<&Path>,
476    ) -> Result<Option<PathBuf>, ProjectContextError> {
477        let preferred = workspace
478            .map(Path::to_path_buf)
479            .or_else(|| session.workspace_path_meta().map(PathBuf::from));
480        if preferred.is_none()
481            && matches!(
482                Self::session_project_identity(session),
483                SessionProjectIdentity::Assigned(_)
484            )
485        {
486            // This synchronous helper cannot consult the Project source.
487            // Assigned callers must use `resolve`, which selects project_path;
488            // they must never fall through to process-global/session-temp
489            // compatibility defaults here.
490            return Ok(None);
491        }
492        workspace_resolver
493            .resolve_session_workspace_candidate(&session.id, preferred)
494            .map(|candidate| resolve_final_workspace_with(&candidate, workspace_resolver))
495            .transpose()
496    }
497
498    async fn resolve_assigned_project(
499        &self,
500        project: ProjectDescriptor,
501        workspace: PathBuf,
502        workspace_source: WorkspaceSource,
503    ) -> Result<ResolvedProjectContext, ProjectContextError> {
504        let binding_status = match self.source.find_workspace_owner(&workspace).await? {
505            Some(owner) if owner == project.id => WorkspaceBindingStatus::Registered,
506            Some(owner) => {
507                return Err(ProjectContextError::WorkspaceConflict {
508                    workspace: workspace.to_string_lossy().into_owned(),
509                    owner_project_id: owner,
510                    session_project_id: project.id.clone(),
511                });
512            }
513            None if project
514                .project_path
515                .iter()
516                .map(PathBuf::as_path)
517                .chain(
518                    project
519                        .workspace_bindings
520                        .iter()
521                        .map(|binding| Path::new(&binding.path)),
522                )
523                .any(|root| path_is_within_binding(root, &workspace)) =>
524            {
525                WorkspaceBindingStatus::Registered
526            }
527            None => WorkspaceBindingStatus::Unregistered,
528        };
529
530        Ok(ResolvedProjectContext {
531            project,
532            workspace: Some(workspace),
533            workspace_source,
534            binding_status,
535        })
536    }
537
538    pub async fn workspace_owner(
539        &self,
540        workspace: &Path,
541    ) -> Result<Option<ProjectId>, ProjectContextError> {
542        self.source.find_workspace_owner(workspace).await
543    }
544
545    /// Resolve and persist the stable Project and mutable Workspace prompt
546    /// markers immediately.
547    ///
548    /// Session-create and chat APIs call this before their first response so a
549    /// freshly-created assigned session is already self-describing when read
550    /// back, rather than waiting for the first execution round. The round
551    /// prelude calls the same helper to keep the markers current.
552    pub async fn refresh_session_prompt(
553        &self,
554        session: &mut Session,
555    ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
556        self.refresh_session_prompt_inner(session, true).await
557    }
558
559    /// Resolve Project/Workspace prompt markers on an in-memory snapshot
560    /// without changing runtime workspace state.
561    ///
562    /// Read APIs use this for sessions that have never entered the runner
563    /// (for example a disabled schedule or a child created with
564    /// `auto_run=false`). The caller is expected to discard the temporary
565    /// session after building the response.
566    pub async fn refresh_session_prompt_read_only(
567        &self,
568        session: &mut Session,
569    ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
570        self.refresh_session_prompt_inner(session, false).await
571    }
572
573    async fn refresh_session_prompt_inner(
574        &self,
575        session: &mut Session,
576        sync_runtime_workspace: bool,
577    ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
578        let resolved = self.resolve(session, None).await?;
579        let workspace = if let Some(context) = resolved.as_ref() {
580            context.workspace.clone()
581        } else {
582            self.resolve_workspace_candidate_for_instance(session, None)?
583        };
584        if let Some(workspace) = workspace.as_deref() {
585            let final_workspace = if sync_runtime_workspace {
586                self.workspace_resolver.publish_resolved_workspace(
587                    &session.id,
588                    workspace.into(),
589                    "project_context_refresh",
590                )
591            } else {
592                workspace.to_path_buf()
593            };
594            session.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
595                &final_workspace,
596            ));
597        }
598        if let Some(context) = resolved.as_ref() {
599            session.metadata.insert(
600                WORKSPACE_SOURCE_METADATA_KEY.to_string(),
601                context.workspace_source.as_str().to_string(),
602            );
603        } else {
604            session.metadata.remove(WORKSPACE_SOURCE_METADATA_KEY);
605        }
606        let current = session
607            .messages
608            .iter()
609            .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
610            .map(|message| message.content.clone())
611            .or_else(|| session.metadata.get("base_system_prompt").cloned())
612            .unwrap_or_default();
613        let mut updated =
614            crate::runtime::context::upsert_project_prompt_context(&current, resolved.as_ref());
615
616        if let Some(context) = resolved.as_ref() {
617            session.metadata.insert(
618                PROJECT_RESOURCES_RENDERED_KEY.to_string(),
619                context.render_resource_inventory(),
620            );
621        } else {
622            session.metadata.remove(PROJECT_RESOURCES_RENDERED_KEY);
623        }
624        let workspace_display = workspace
625            .as_deref()
626            .map(bamboo_config::paths::path_to_display_string);
627        updated = crate::runtime::context::upsert_workspace_prompt_context_with_source(
628            &updated,
629            workspace_display.as_deref(),
630            resolved
631                .as_ref()
632                .map(|context| context.binding_status)
633                .unwrap_or(WorkspaceBindingStatus::Unregistered),
634            resolved.as_ref().map(|context| context.workspace_source),
635        );
636
637        if let Some(system_message) = session
638            .messages
639            .iter_mut()
640            .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
641        {
642            system_message.content = updated;
643        } else if !updated.trim().is_empty() {
644            session
645                .messages
646                .insert(0, bamboo_agent_core::Message::system(updated));
647        }
648        crate::runner::refresh_prompt_snapshot(session);
649
650        Ok(resolved)
651    }
652}
653
654fn resolve_final_workspace_with(
655    workspace: &Path,
656    workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
657) -> Result<PathBuf, ProjectContextError> {
658    if workspace.exists() && !workspace.is_dir() {
659        return Err(ProjectContextError::WorkspaceInvalid {
660            workspace: workspace.to_string_lossy().into_owned(),
661            message: "path is not a directory".to_string(),
662        });
663    }
664    let canonical = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
665    let final_workspace = workspace_resolver.preview_workspace_path(canonical);
666    if final_workspace.exists() && !final_workspace.is_dir() {
667        return Err(ProjectContextError::WorkspaceInvalid {
668            workspace: final_workspace.to_string_lossy().into_owned(),
669            message: "resolved path is not a directory".to_string(),
670        });
671    }
672    Ok(std::fs::canonicalize(&final_workspace).unwrap_or(final_workspace))
673}
674
675/// Resolve an already-selected workspace with the same existence, directory,
676/// and instance-confinement checks used by assigned runtime resolution.
677///
678/// Server-side Project/Workspace tools use this seam so read diagnostics
679/// cannot report a stale or differently confined workspace as runnable.
680pub fn resolve_existing_workspace_with_resolver(
681    workspace: &Path,
682    workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
683) -> Result<PathBuf, ProjectContextError> {
684    if !workspace.exists() {
685        return Err(ProjectContextError::WorkspaceInvalid {
686            workspace: workspace.to_string_lossy().into_owned(),
687            message: "path does not exist".to_string(),
688        });
689    }
690    resolve_final_workspace_with(workspace, workspace_resolver)
691}
692
693fn resolve_project_default_workspace(
694    project: &ProjectDescriptor,
695    workspace_resolver: &bamboo_agent_core::workspace_state::WorkspaceResolver,
696) -> Result<PathBuf, ProjectContextError> {
697    let project_path =
698        project
699            .project_path
700            .as_deref()
701            .ok_or_else(|| ProjectContextError::ProjectPathMissing {
702                project_id: project.id.clone(),
703            })?;
704    let display = project_path.to_string_lossy().into_owned();
705    if !project_path.exists() {
706        return Err(ProjectContextError::ProjectPathUnavailable {
707            project_id: project.id.clone(),
708            project_path: display,
709            message: "path does not exist".to_string(),
710        });
711    }
712    let metadata = std::fs::symlink_metadata(project_path).map_err(|error| {
713        ProjectContextError::ProjectPathUnavailable {
714            project_id: project.id.clone(),
715            project_path: display.clone(),
716            message: format!("path metadata is unavailable: {error}"),
717        }
718    })?;
719    if metadata.file_type().is_symlink() || !metadata.is_dir() {
720        return Err(ProjectContextError::ProjectPathUnavailable {
721            project_id: project.id.clone(),
722            project_path: display,
723            message: "configured path is no longer a plain directory".to_string(),
724        });
725    }
726    let canonical = std::fs::canonicalize(project_path).map_err(|error| {
727        ProjectContextError::ProjectPathUnavailable {
728            project_id: project.id.clone(),
729            project_path: display.clone(),
730            message: format!("path could not be canonicalized: {error}"),
731        }
732    })?;
733    if canonical != project_path {
734        return Err(ProjectContextError::ProjectPathUnavailable {
735            project_id: project.id.clone(),
736            project_path: display,
737            message: "configured path no longer resolves to its registered canonical directory"
738                .to_string(),
739        });
740    }
741    let final_workspace = workspace_resolver.preview_workspace_path(canonical.clone());
742    let final_workspace = std::fs::canonicalize(&final_workspace).map_err(|error| {
743        ProjectContextError::ProjectPathUnavailable {
744            project_id: project.id.clone(),
745            project_path: display.clone(),
746            message: format!("confinement target is unavailable: {error}"),
747        }
748    })?;
749    if final_workspace != canonical {
750        return Err(ProjectContextError::ProjectPathUnavailable {
751            project_id: project.id.clone(),
752            project_path: display,
753            message: format!(
754                "workspace confinement redirected the Project path to '{}'",
755                final_workspace.display()
756            ),
757        });
758    }
759    Ok(canonical)
760}
761
762fn path_is_within_binding(binding: &Path, candidate: &Path) -> bool {
763    let Ok(metadata) = std::fs::symlink_metadata(binding) else {
764        return false;
765    };
766    if metadata.file_type().is_symlink() || !metadata.is_dir() {
767        return false;
768    }
769    let Ok(canonical_binding) = std::fs::canonicalize(binding) else {
770        return false;
771    };
772    if canonical_binding != binding {
773        return false;
774    }
775    candidate == binding || candidate.starts_with(binding)
776}
777
778#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
779#[serde(rename_all = "snake_case")]
780pub enum ProjectResourceLayer {
781    Project,
782    Workspace,
783}
784
785#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
786pub struct ProjectResourceCandidate {
787    pub kind: ProjectResourceKind,
788    pub layer: ProjectResourceLayer,
789    pub path: PathBuf,
790    pub exists: bool,
791}
792
793#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
794pub struct ProjectResourceDiagnostic {
795    pub project_id: ProjectId,
796    pub resource_revision: u64,
797    pub workspace_binding_status: WorkspaceBindingStatus,
798    pub candidates: Vec<ProjectResourceCandidate>,
799}
800
801/// Stable Project-home resources plus the current workspace overlay.
802#[derive(Debug, Clone, PartialEq, Eq)]
803pub struct ProjectResourceScope {
804    pub project_id: ProjectId,
805    pub project_home: PathBuf,
806    pub workspace: Option<PathBuf>,
807    pub binding_status: WorkspaceBindingStatus,
808    pub resource_revision: u64,
809}
810
811impl ProjectResourceScope {
812    pub fn project_memory_root(&self) -> PathBuf {
813        self.project_home.join("memory").join("v1")
814    }
815
816    pub fn project_skills_dir(&self) -> PathBuf {
817        self.project_home.join("skills")
818    }
819
820    pub fn project_mode_skills_dir(&self, mode: &str) -> PathBuf {
821        self.project_home.join(format!("skills-{mode}"))
822    }
823
824    pub fn workspace_skills_dir(&self) -> Option<PathBuf> {
825        self.workspace
826            .as_ref()
827            .map(|workspace| workspace.join(".bamboo").join("skills"))
828    }
829
830    pub fn workspace_mode_skills_dir(&self, mode: &str) -> Option<PathBuf> {
831        self.workspace
832            .as_ref()
833            .map(|workspace| workspace.join(".bamboo").join(format!("skills-{mode}")))
834    }
835
836    pub fn project_commands_dir(&self) -> PathBuf {
837        self.project_home.join("commands")
838    }
839
840    pub fn workspace_commands_dir(&self) -> Option<PathBuf> {
841        let workspace = self.workspace.as_deref()?;
842        let boundary = nearest_git_boundary(workspace).unwrap_or_else(|| workspace.to_path_buf());
843        Some(boundary.join(".bamboo").join("commands"))
844    }
845
846    /// Ordinary resource precedence is Project first, then the more-specific
847    /// workspace overlay. Security policies must use their dedicated managed /
848    /// deny / trust merge logic instead of this shadowing order.
849    pub fn candidates(&self, kind: ProjectResourceKind) -> Vec<ProjectResourceCandidate> {
850        let mut paths = match kind {
851            ProjectResourceKind::Settings => vec![(
852                ProjectResourceLayer::Project,
853                self.project_home.join("settings.json"),
854            )],
855            ProjectResourceKind::Memory => {
856                vec![(ProjectResourceLayer::Project, self.project_memory_root())]
857            }
858            ProjectResourceKind::Skills => {
859                let mut values = vec![(ProjectResourceLayer::Project, self.project_skills_dir())];
860                if let Some(path) = self.workspace_skills_dir() {
861                    values.push((ProjectResourceLayer::Workspace, path));
862                }
863                values
864            }
865            ProjectResourceKind::Commands => {
866                let mut values = vec![(ProjectResourceLayer::Project, self.project_commands_dir())];
867                if let Some(path) = self.workspace_commands_dir() {
868                    values.push((ProjectResourceLayer::Workspace, path));
869                }
870                values
871            }
872            ProjectResourceKind::Artifacts => vec![(
873                ProjectResourceLayer::Project,
874                self.project_home.join("artifacts"),
875            )],
876            ProjectResourceKind::State => vec![(
877                ProjectResourceLayer::Project,
878                self.project_home.join("state"),
879            )],
880        };
881
882        paths
883            .drain(..)
884            .map(|(layer, path)| ProjectResourceCandidate {
885                kind,
886                layer,
887                exists: path.exists(),
888                path,
889            })
890            .collect()
891    }
892
893    pub fn diagnostic(&self) -> ProjectResourceDiagnostic {
894        let mut candidates = Vec::new();
895        for kind in [
896            ProjectResourceKind::Settings,
897            ProjectResourceKind::Memory,
898            ProjectResourceKind::Skills,
899            ProjectResourceKind::Commands,
900            ProjectResourceKind::Artifacts,
901            ProjectResourceKind::State,
902        ] {
903            candidates.extend(self.candidates(kind));
904        }
905        ProjectResourceDiagnostic {
906            project_id: self.project_id.clone(),
907            resource_revision: self.resource_revision,
908            workspace_binding_status: self.binding_status,
909            candidates,
910        }
911    }
912}
913
914fn nearest_git_boundary(start: &Path) -> Option<PathBuf> {
915    start
916        .ancestors()
917        .find(|candidate| {
918            let git = candidate.join(".git");
919            git.is_dir() || git.is_file()
920        })
921        .map(Path::to_path_buf)
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927
928    struct StaticSource(ProjectDescriptor);
929
930    #[async_trait]
931    impl ProjectContextSource for StaticSource {
932        async fn find_project(
933            &self,
934            project_id: &ProjectId,
935        ) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
936            Ok((&self.0.id == project_id).then(|| self.0.clone()))
937        }
938    }
939
940    struct OwnedWorkspaceSource {
941        descriptor: ProjectDescriptor,
942        owner: ProjectId,
943    }
944
945    #[async_trait]
946    impl ProjectContextSource for OwnedWorkspaceSource {
947        async fn find_project(
948            &self,
949            project_id: &ProjectId,
950        ) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
951            Ok((&self.descriptor.id == project_id).then(|| self.descriptor.clone()))
952        }
953
954        async fn find_workspace_owner(
955            &self,
956            _workspace: &Path,
957        ) -> Result<Option<ProjectId>, ProjectContextError> {
958            Ok(Some(self.owner.clone()))
959        }
960    }
961
962    #[tokio::test]
963    async fn workspace_changes_do_not_change_project_identity_or_home() {
964        let directory = tempfile::tempdir().expect("tempdir");
965        let main = directory.path().join("main");
966        let worktree = directory.path().join("worktree");
967        std::fs::create_dir_all(&main).expect("main");
968        std::fs::create_dir_all(&worktree).expect("worktree");
969        let main = main.canonicalize().expect("canonical main");
970        let worktree = worktree.canonicalize().expect("canonical worktree");
971        let project_id = ProjectId::parse("01JPROJECT00000000000000000").expect("project id");
972        let descriptor = ProjectDescriptor {
973            id: project_id.clone(),
974            name: "Zenith".to_string(),
975            project_path: Some(main.clone()),
976            home: directory
977                .path()
978                .join("projects/01JPROJECT00000000000000000"),
979            workspace_bindings: vec![
980                WorkspaceBinding {
981                    path: main.to_string_lossy().to_string(),
982                    label: Some("main".to_string()),
983                    git_common_dir: None,
984                },
985                WorkspaceBinding {
986                    path: worktree.to_string_lossy().to_string(),
987                    label: Some("worktree".to_string()),
988                    git_common_dir: None,
989                },
990            ],
991            resources: bamboo_domain::ProjectResourceSummary {
992                project_id: project_id.clone(),
993                resource_revision: 7,
994                resources: Vec::new(),
995            },
996            memory_read_roots: ProjectMemoryReadRoots {
997                primary: directory
998                    .path()
999                    .join("projects/01JPROJECT00000000000000000/memory/v1"),
1000                legacy_aliases: Vec::new(),
1001            },
1002        };
1003        let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1004        let mut session = Session::new("session-1", "test");
1005        session.set_project_id_meta(project_id.to_string());
1006
1007        let first = resolver
1008            .resolve(&session, Some(&main))
1009            .await
1010            .expect("resolve")
1011            .expect("assigned");
1012        let second = resolver
1013            .resolve(&session, Some(&worktree))
1014            .await
1015            .expect("resolve")
1016            .expect("assigned");
1017        assert_eq!(first.project.id, second.project.id);
1018        assert_eq!(first.project.home, second.project.home);
1019        assert_eq!(second.binding_status, WorkspaceBindingStatus::Registered);
1020    }
1021
1022    #[tokio::test]
1023    async fn assigned_session_uses_project_path_before_global_or_temp_fallback() {
1024        let directory = tempfile::tempdir().expect("tempdir");
1025        let project_path = directory.path().join("project");
1026        let foreign_default = directory.path().join("foreign-default");
1027        let workspace_root = directory.path().join("session-workspaces");
1028        std::fs::create_dir_all(&project_path).expect("project path");
1029        std::fs::create_dir_all(&foreign_default).expect("foreign default");
1030        std::fs::create_dir_all(&workspace_root).expect("workspace root");
1031        let project_id = ProjectId::parse("project-default").expect("Project id");
1032        let descriptor = ProjectDescriptor {
1033            id: project_id.clone(),
1034            name: "Project Default".to_string(),
1035            project_path: Some(project_path.canonicalize().unwrap()),
1036            home: directory.path().join("projects/project-default"),
1037            workspace_bindings: Vec::new(),
1038            resources: bamboo_domain::ProjectResourceSummary {
1039                project_id: project_id.clone(),
1040                resource_revision: 1,
1041                resources: Vec::new(),
1042            },
1043            memory_read_roots: ProjectMemoryReadRoots {
1044                primary: directory.path().join("projects/project-default/memory/v1"),
1045                legacy_aliases: Vec::new(),
1046            },
1047        };
1048        let resolver = ProjectContextResolver::new_with_workspace_resolver(
1049            Arc::new(StaticSource(descriptor)),
1050            bamboo_agent_core::workspace_state::WorkspaceResolver::new(
1051                {
1052                    let foreign_default = foreign_default.clone();
1053                    move || Some(foreign_default.clone())
1054                },
1055                {
1056                    let workspace_root = workspace_root.clone();
1057                    move || bamboo_agent_core::workspace_state::WorkspaceRootConfig {
1058                        root: workspace_root.clone(),
1059                        confine: false,
1060                    }
1061                },
1062            ),
1063        );
1064        let mut session = Session::new("project-default-session", "test");
1065        session.set_project_id_meta(project_id.to_string());
1066        session
1067            .messages
1068            .insert(0, bamboo_agent_core::Message::system("base"));
1069
1070        let resolved = resolver
1071            .refresh_session_prompt_read_only(&mut session)
1072            .await
1073            .expect("Project default must resolve")
1074            .expect("assigned Project");
1075        assert_eq!(
1076            resolved.workspace.as_deref(),
1077            Some(project_path.canonicalize().unwrap().as_path())
1078        );
1079        assert_eq!(resolved.workspace_source, WorkspaceSource::ProjectDefault);
1080        assert_eq!(
1081            session.workspace_path_meta().as_deref(),
1082            Some(
1083                bamboo_config::paths::path_to_display_string(&project_path.canonicalize().unwrap())
1084                    .as_str()
1085            )
1086        );
1087        let prompt = &session.messages[0].content;
1088        assert_eq!(prompt.matches("Project path:").count(), 1);
1089        assert_eq!(prompt.matches("Project home (Bamboo data):").count(), 1);
1090        assert_eq!(prompt.matches("Workspace path:").count(), 1);
1091        assert_eq!(
1092            prompt.matches("Workspace source: project_default").count(),
1093            1
1094        );
1095        assert!(!prompt.contains(foreign_default.to_string_lossy().as_ref()));
1096        assert!(!workspace_root.join(&session.id).exists());
1097
1098        let moved_project_path = directory.path().join("project-moved");
1099        std::fs::create_dir_all(&moved_project_path).expect("moved Project path");
1100        let moved_descriptor = ProjectDescriptor {
1101            id: project_id,
1102            name: "Project Default".to_string(),
1103            project_path: Some(moved_project_path.canonicalize().unwrap()),
1104            home: directory.path().join("projects/project-default"),
1105            workspace_bindings: Vec::new(),
1106            resources: bamboo_domain::ProjectResourceSummary {
1107                project_id: ProjectId::parse("project-default").unwrap(),
1108                resource_revision: 2,
1109                resources: Vec::new(),
1110            },
1111            memory_read_roots: ProjectMemoryReadRoots {
1112                primary: directory.path().join("projects/project-default/memory/v1"),
1113                legacy_aliases: Vec::new(),
1114            },
1115        };
1116        let moved_resolver = ProjectContextResolver::new(Arc::new(StaticSource(moved_descriptor)));
1117        let moved = moved_resolver
1118            .resolve(&session, None)
1119            .await
1120            .expect("moved Project path")
1121            .expect("assigned Project");
1122        assert_eq!(
1123            moved.workspace.as_deref(),
1124            Some(moved_project_path.canonicalize().unwrap().as_path())
1125        );
1126        assert_eq!(moved.workspace_source, WorkspaceSource::ProjectDefault);
1127    }
1128
1129    #[tokio::test]
1130    async fn legacy_persisted_workspace_equal_to_project_path_remains_session_owned_after_path_cas()
1131    {
1132        let directory = tempfile::tempdir().expect("tempdir");
1133        let project_path = directory.path().join("project");
1134        let moved_project_path = directory.path().join("project-moved");
1135        std::fs::create_dir_all(&project_path).expect("Project path");
1136        std::fs::create_dir_all(&moved_project_path).expect("moved Project path");
1137        let project_path = project_path.canonicalize().expect("canonical Project path");
1138        let moved_project_path = moved_project_path
1139            .canonicalize()
1140            .expect("canonical moved Project path");
1141        let project_id = ProjectId::parse("legacy-persisted-workspace").expect("Project id");
1142        let descriptor = ProjectDescriptor {
1143            id: project_id.clone(),
1144            name: "Legacy persisted workspace".to_string(),
1145            project_path: Some(project_path.clone()),
1146            home: directory.path().join("projects/legacy-persisted-workspace"),
1147            workspace_bindings: Vec::new(),
1148            resources: bamboo_domain::ProjectResourceSummary {
1149                project_id: project_id.clone(),
1150                resource_revision: 1,
1151                resources: Vec::new(),
1152            },
1153            memory_read_roots: ProjectMemoryReadRoots {
1154                primary: directory
1155                    .path()
1156                    .join("projects/legacy-persisted-workspace/memory/v1"),
1157                legacy_aliases: Vec::new(),
1158            },
1159        };
1160        let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1161        let mut session = Session::new("legacy-persisted-workspace", "test");
1162        session.set_project_id_meta(project_id.to_string());
1163        session
1164            .set_workspace_path_meta(bamboo_config::paths::path_to_display_string(&project_path));
1165
1166        let initial = resolver
1167            .refresh_session_prompt_read_only(&mut session)
1168            .await
1169            .expect("legacy persisted workspace must resolve")
1170            .expect("assigned Project");
1171        assert_eq!(initial.workspace.as_deref(), Some(project_path.as_path()));
1172        assert_eq!(initial.workspace_source, WorkspaceSource::Session);
1173        assert_eq!(
1174            session
1175                .metadata
1176                .get(WORKSPACE_SOURCE_METADATA_KEY)
1177                .map(String::as_str),
1178            Some("session")
1179        );
1180
1181        let moved_descriptor = ProjectDescriptor {
1182            id: project_id.clone(),
1183            name: "Legacy persisted workspace".to_string(),
1184            project_path: Some(moved_project_path),
1185            home: directory.path().join("projects/legacy-persisted-workspace"),
1186            workspace_bindings: Vec::new(),
1187            resources: bamboo_domain::ProjectResourceSummary {
1188                project_id,
1189                resource_revision: 2,
1190                resources: Vec::new(),
1191            },
1192            memory_read_roots: ProjectMemoryReadRoots {
1193                primary: directory
1194                    .path()
1195                    .join("projects/legacy-persisted-workspace/memory/v1"),
1196                legacy_aliases: Vec::new(),
1197            },
1198        };
1199        let moved_resolver = ProjectContextResolver::new(Arc::new(StaticSource(moved_descriptor)));
1200        let after_cas = moved_resolver
1201            .resolve(&session, None)
1202            .await
1203            .expect("persisted workspace must remain authoritative")
1204            .expect("assigned Project");
1205        assert_eq!(after_cas.workspace.as_deref(), Some(project_path.as_path()));
1206        assert_eq!(after_cas.workspace_source, WorkspaceSource::Session);
1207    }
1208
1209    #[cfg(unix)]
1210    #[tokio::test]
1211    async fn project_default_rejects_configured_path_replaced_by_symlink() {
1212        use std::os::unix::fs::symlink;
1213
1214        let directory = tempfile::tempdir().expect("tempdir");
1215        let project_path = directory.path().join("project");
1216        let replacement = directory.path().join("replacement");
1217        std::fs::create_dir_all(&project_path).expect("Project path");
1218        std::fs::create_dir_all(&replacement).expect("replacement");
1219        let configured_path = project_path.canonicalize().expect("canonical Project path");
1220        let project_id = ProjectId::parse("symlink-replaced-project").expect("Project id");
1221        let descriptor = ProjectDescriptor {
1222            id: project_id.clone(),
1223            name: "Symlink replacement".to_string(),
1224            project_path: Some(configured_path),
1225            home: directory.path().join("projects/symlink-replaced-project"),
1226            workspace_bindings: Vec::new(),
1227            resources: bamboo_domain::ProjectResourceSummary {
1228                project_id: project_id.clone(),
1229                resource_revision: 1,
1230                resources: Vec::new(),
1231            },
1232            memory_read_roots: ProjectMemoryReadRoots {
1233                primary: directory
1234                    .path()
1235                    .join("projects/symlink-replaced-project/memory/v1"),
1236                legacy_aliases: Vec::new(),
1237            },
1238        };
1239        let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1240        let mut session = Session::new("symlink-replaced-project", "test");
1241        session.set_project_id_meta(project_id.to_string());
1242
1243        std::fs::remove_dir(&project_path).expect("remove configured Project path");
1244        symlink(&replacement, &project_path).expect("replace Project path with symlink");
1245
1246        assert!(matches!(
1247            resolver.resolve(&session, None).await,
1248            Err(ProjectContextError::ProjectPathUnavailable { .. })
1249        ));
1250        assert!(session.workspace_path_meta().is_none());
1251    }
1252
1253    #[cfg(unix)]
1254    #[tokio::test]
1255    async fn explicit_workspace_is_not_registered_through_a_replaced_project_path_symlink() {
1256        use std::os::unix::fs::symlink;
1257
1258        let directory = tempfile::tempdir().expect("tempdir");
1259        let project_path = directory.path().join("project");
1260        let replacement = directory.path().join("replacement");
1261        let explicit_workspace = replacement.join("explicit");
1262        std::fs::create_dir_all(&project_path).expect("Project path");
1263        std::fs::create_dir_all(&explicit_workspace).expect("explicit workspace");
1264        let configured_path = project_path.canonicalize().expect("canonical Project path");
1265        let project_id = ProjectId::parse("symlink-explicit-project").expect("Project id");
1266        let descriptor = ProjectDescriptor {
1267            id: project_id.clone(),
1268            name: "Symlink explicit workspace".to_string(),
1269            project_path: Some(configured_path),
1270            home: directory.path().join("projects/symlink-explicit-project"),
1271            workspace_bindings: Vec::new(),
1272            resources: bamboo_domain::ProjectResourceSummary {
1273                project_id: project_id.clone(),
1274                resource_revision: 1,
1275                resources: Vec::new(),
1276            },
1277            memory_read_roots: ProjectMemoryReadRoots {
1278                primary: directory
1279                    .path()
1280                    .join("projects/symlink-explicit-project/memory/v1"),
1281                legacy_aliases: Vec::new(),
1282            },
1283        };
1284        let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1285        let mut session = Session::new("symlink-explicit-project", "test");
1286        session.set_project_id_meta(project_id.to_string());
1287
1288        std::fs::remove_dir(&project_path).expect("remove configured Project path");
1289        symlink(&replacement, &project_path).expect("replace Project path with symlink");
1290
1291        let resolved = resolver
1292            .resolve(&session, Some(&explicit_workspace))
1293            .await
1294            .expect("explicit workspace remains independently resolvable")
1295            .expect("assigned Project");
1296        assert_eq!(
1297            resolved.workspace.as_deref(),
1298            Some(explicit_workspace.canonicalize().unwrap().as_path())
1299        );
1300        assert_eq!(
1301            resolved.binding_status,
1302            WorkspaceBindingStatus::Unregistered
1303        );
1304        assert_eq!(resolved.workspace_source, WorkspaceSource::Explicit);
1305    }
1306
1307    #[tokio::test]
1308    async fn missing_or_unavailable_project_path_fails_closed() {
1309        let directory = tempfile::tempdir().expect("tempdir");
1310        let project_id = ProjectId::parse("unconfigured-project").expect("Project id");
1311        let descriptor = ProjectDescriptor {
1312            id: project_id.clone(),
1313            name: "Unconfigured".to_string(),
1314            project_path: None,
1315            home: directory.path().join("projects/unconfigured-project"),
1316            workspace_bindings: Vec::new(),
1317            resources: bamboo_domain::ProjectResourceSummary {
1318                project_id: project_id.clone(),
1319                resource_revision: 1,
1320                resources: Vec::new(),
1321            },
1322            memory_read_roots: ProjectMemoryReadRoots {
1323                primary: directory
1324                    .path()
1325                    .join("projects/unconfigured-project/memory/v1"),
1326                legacy_aliases: Vec::new(),
1327            },
1328        };
1329        let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1330        let mut session = Session::new("unconfigured", "test");
1331        session.set_project_id_meta(project_id.to_string());
1332        assert!(matches!(
1333            resolver.resolve(&session, None).await,
1334            Err(ProjectContextError::ProjectPathMissing { .. })
1335        ));
1336
1337        let missing = directory.path().join("moved-away");
1338        let descriptor = ProjectDescriptor {
1339            id: project_id.clone(),
1340            name: "Unavailable".to_string(),
1341            project_path: Some(missing.clone()),
1342            home: directory.path().join("projects/unconfigured-project"),
1343            workspace_bindings: Vec::new(),
1344            resources: bamboo_domain::ProjectResourceSummary {
1345                project_id: project_id.clone(),
1346                resource_revision: 1,
1347                resources: Vec::new(),
1348            },
1349            memory_read_roots: ProjectMemoryReadRoots {
1350                primary: directory
1351                    .path()
1352                    .join("projects/unconfigured-project/memory/v1"),
1353                legacy_aliases: Vec::new(),
1354            },
1355        };
1356        let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1357        assert!(matches!(
1358            resolver.resolve(&session, None).await,
1359            Err(ProjectContextError::ProjectPathUnavailable {
1360                project_path,
1361                ..
1362            }) if project_path == missing.to_string_lossy()
1363        ));
1364        assert!(session.workspace_path_meta().is_none());
1365    }
1366
1367    #[tokio::test]
1368    async fn prompt_refresh_removes_stale_project_and_updates_unassigned_workspace() {
1369        let directory = tempfile::tempdir().expect("tempdir");
1370        let workspace = directory.path().join("workspace");
1371        std::fs::create_dir_all(&workspace).expect("workspace");
1372        let workspace = workspace.canonicalize().expect("canonical workspace");
1373        let project_id = ProjectId::parse("project-prompt-refresh").expect("Project id");
1374        let descriptor = ProjectDescriptor {
1375            id: project_id.clone(),
1376            name: "Prompt Project".to_string(),
1377            project_path: Some(workspace.clone()),
1378            home: directory.path().join("projects/project-prompt-refresh"),
1379            workspace_bindings: vec![WorkspaceBinding {
1380                path: workspace.to_string_lossy().into_owned(),
1381                label: None,
1382                git_common_dir: None,
1383            }],
1384            resources: bamboo_domain::ProjectResourceSummary {
1385                project_id: project_id.clone(),
1386                resource_revision: 1,
1387                resources: Vec::new(),
1388            },
1389            memory_read_roots: ProjectMemoryReadRoots {
1390                primary: directory
1391                    .path()
1392                    .join("projects/project-prompt-refresh/memory/v1"),
1393                legacy_aliases: Vec::new(),
1394            },
1395        };
1396        let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1397        let mut session = Session::new("prompt-project-switch", "test");
1398        session
1399            .messages
1400            .insert(0, bamboo_agent_core::Message::system("base"));
1401        session.set_project_id_meta(project_id.to_string());
1402        session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
1403
1404        resolver
1405            .refresh_session_prompt(&mut session)
1406            .await
1407            .expect("assigned refresh");
1408        let assigned = &session.messages[0].content;
1409        assert_eq!(assigned.matches("BAMBOO_PROJECT_CONTEXT_START").count(), 1);
1410        assert!(assigned.contains("Binding status: registered"));
1411
1412        session.clear_project_id_meta();
1413        resolver
1414            .refresh_session_prompt(&mut session)
1415            .await
1416            .expect("unassigned refresh");
1417        let unassigned = &session.messages[0].content;
1418        assert_eq!(
1419            unassigned.matches("BAMBOO_PROJECT_CONTEXT_START").count(),
1420            0
1421        );
1422        assert_eq!(
1423            unassigned.matches("BAMBOO_WORKSPACE_CONTEXT_START").count(),
1424            1
1425        );
1426        assert!(unassigned.contains("Binding status: unregistered"));
1427        assert!(!session
1428            .metadata
1429            .contains_key(PROJECT_RESOURCES_RENDERED_KEY));
1430    }
1431
1432    #[tokio::test]
1433    async fn workspace_owned_by_another_project_fails_closed() {
1434        let directory = tempfile::tempdir().expect("tempdir");
1435        let workspace = directory.path().join("workspace");
1436        std::fs::create_dir_all(&workspace).expect("workspace");
1437        let project_id = ProjectId::parse("project-a").expect("project id");
1438        let owner = ProjectId::parse("project-b").expect("owner id");
1439        let descriptor = ProjectDescriptor {
1440            id: project_id.clone(),
1441            name: "Project A".to_string(),
1442            project_path: Some(workspace.clone()),
1443            home: directory.path().join("projects/project-a"),
1444            workspace_bindings: vec![WorkspaceBinding {
1445                path: workspace.to_string_lossy().into_owned(),
1446                label: None,
1447                git_common_dir: None,
1448            }],
1449            resources: bamboo_domain::ProjectResourceSummary {
1450                project_id: project_id.clone(),
1451                resource_revision: 1,
1452                resources: Vec::new(),
1453            },
1454            memory_read_roots: ProjectMemoryReadRoots {
1455                primary: directory.path().join("projects/project-a/memory/v1"),
1456                legacy_aliases: Vec::new(),
1457            },
1458        };
1459        let resolver = ProjectContextResolver::new(Arc::new(OwnedWorkspaceSource {
1460            descriptor,
1461            owner: owner.clone(),
1462        }));
1463        let mut session = Session::new("session-conflict", "test");
1464        session.set_project_id_meta(project_id.to_string());
1465
1466        let error = resolver
1467            .resolve(&session, Some(&workspace))
1468            .await
1469            .expect_err("cross-Project workspace must fail closed");
1470        assert!(matches!(
1471            error,
1472            ProjectContextError::WorkspaceConflict {
1473                owner_project_id,
1474                session_project_id,
1475                ..
1476            } if owner_project_id == owner && session_project_id == project_id
1477        ));
1478
1479        let safe_workspace = directory.path().join("safe");
1480        std::fs::create_dir_all(&safe_workspace).expect("safe workspace");
1481        let safe_canonical = safe_workspace.canonicalize().expect("canonical safe");
1482        bamboo_tools::tools::workspace_state::set_workspace(&session.id, safe_canonical.clone());
1483        session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
1484        let error = resolver
1485            .refresh_session_prompt(&mut session)
1486            .await
1487            .expect_err("refresh must validate before publishing workspace");
1488        assert!(matches!(
1489            error,
1490            ProjectContextError::WorkspaceConflict { .. }
1491        ));
1492        assert_eq!(
1493            bamboo_tools::tools::workspace_state::get_workspace(&session.id).as_deref(),
1494            Some(safe_canonical.as_path()),
1495            "a post-preflight ownership change must not publish the rejected workspace"
1496        );
1497    }
1498
1499    #[tokio::test]
1500    async fn malformed_project_identity_never_falls_back_to_legacy_workspace_scope() {
1501        let directory = tempfile::tempdir().expect("tempdir");
1502        let workspace = directory.path().join("workspace");
1503        std::fs::create_dir_all(&workspace).expect("workspace");
1504        let descriptor_id = ProjectId::parse("descriptor").expect("Project id");
1505        let descriptor = ProjectDescriptor {
1506            id: descriptor_id.clone(),
1507            name: "Descriptor".to_string(),
1508            project_path: Some(workspace.clone()),
1509            home: directory.path().join("projects/descriptor"),
1510            workspace_bindings: Vec::new(),
1511            resources: bamboo_domain::ProjectResourceSummary {
1512                project_id: descriptor_id.clone(),
1513                resource_revision: 1,
1514                resources: Vec::new(),
1515            },
1516            memory_read_roots: ProjectMemoryReadRoots {
1517                primary: directory.path().join("projects/descriptor/memory/v1"),
1518                legacy_aliases: Vec::new(),
1519            },
1520        };
1521        let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
1522        let mut session = Session::new("malformed", "test");
1523        session.set_project_id_meta("../malformed");
1524        session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
1525
1526        assert!(ProjectContextResolver::memory_read_identity_for_session(&session).is_none());
1527        assert!(matches!(
1528            resolver.resolve(&session, Some(&workspace)).await,
1529            Err(ProjectContextError::InvalidProjectIdentity { .. })
1530        ));
1531        assert!(matches!(
1532            resolver
1533                .resolve_memory_read_scope(&session, Some(&workspace))
1534                .await,
1535            Err(ProjectContextError::InvalidProjectIdentity { .. })
1536        ));
1537    }
1538
1539    #[tokio::test]
1540    async fn unassigned_session_cannot_resolve_a_project_owned_workspace() {
1541        let directory = tempfile::tempdir().expect("tempdir");
1542        let workspace = directory.path().join("workspace");
1543        std::fs::create_dir_all(&workspace).expect("workspace");
1544        let descriptor_id = ProjectId::parse("descriptor").expect("Project id");
1545        let owner = ProjectId::parse("owner").expect("owner Project id");
1546        let descriptor = ProjectDescriptor {
1547            id: descriptor_id.clone(),
1548            name: "Descriptor".to_string(),
1549            project_path: None,
1550            home: directory.path().join("projects/descriptor"),
1551            workspace_bindings: Vec::new(),
1552            resources: bamboo_domain::ProjectResourceSummary {
1553                project_id: descriptor_id,
1554                resource_revision: 1,
1555                resources: Vec::new(),
1556            },
1557            memory_read_roots: ProjectMemoryReadRoots {
1558                primary: directory.path().join("projects/descriptor/memory/v1"),
1559                legacy_aliases: Vec::new(),
1560            },
1561        };
1562        let resolver = ProjectContextResolver::new(Arc::new(OwnedWorkspaceSource {
1563            descriptor,
1564            owner: owner.clone(),
1565        }));
1566        let session = Session::new("unassigned", "test");
1567
1568        assert!(matches!(
1569            resolver.resolve(&session, Some(&workspace)).await,
1570            Err(ProjectContextError::UnassignedWorkspaceConflict {
1571                owner_project_id,
1572                ..
1573            }) if owner_project_id == owner
1574        ));
1575    }
1576
1577    #[test]
1578    fn project_identity_parser_trims_like_the_storage_index() {
1579        let mut session = Session::new("whitespace", "test");
1580        session.set_project_id_meta("  project-1  ");
1581        assert_eq!(
1582            ProjectContextResolver::session_project_identity(&session),
1583            SessionProjectIdentity::Assigned(ProjectId::parse("project-1").unwrap())
1584        );
1585    }
1586
1587    #[test]
1588    fn resource_diagnostic_distinguishes_project_and_workspace_layers() {
1589        let directory = tempfile::tempdir().expect("tempdir");
1590        let project_home = directory.path().join("project-home");
1591        let workspace = directory.path().join("workspace");
1592        std::fs::create_dir_all(project_home.join("skills")).expect("project skills");
1593        std::fs::create_dir_all(workspace.join(".bamboo/skills")).expect("workspace skills");
1594        let scope = ProjectResourceScope {
1595            project_id: ProjectId::parse("project-1").expect("project id"),
1596            project_home,
1597            workspace: Some(workspace),
1598            binding_status: WorkspaceBindingStatus::Registered,
1599            resource_revision: 4,
1600        };
1601
1602        let candidates = scope.candidates(ProjectResourceKind::Skills);
1603        assert_eq!(candidates.len(), 2);
1604        assert_eq!(candidates[0].layer, ProjectResourceLayer::Project);
1605        assert_eq!(candidates[1].layer, ProjectResourceLayer::Workspace);
1606        assert!(candidates.iter().all(|candidate| candidate.exists));
1607    }
1608
1609    #[test]
1610    fn workspace_commands_resolve_from_nearest_git_boundary() {
1611        let directory = tempfile::tempdir().expect("tempdir");
1612        let repository = directory.path().join("repo");
1613        let nested = repository.join("nested/path");
1614        std::fs::create_dir_all(repository.join(".git")).expect("git");
1615        std::fs::create_dir_all(&nested).expect("nested");
1616        let scope = ProjectResourceScope {
1617            project_id: ProjectId::parse("project-1").expect("project id"),
1618            project_home: directory.path().join("project-home"),
1619            workspace: Some(nested),
1620            binding_status: WorkspaceBindingStatus::Registered,
1621            resource_revision: 1,
1622        };
1623        assert_eq!(
1624            scope.workspace_commands_dir(),
1625            Some(repository.join(".bamboo/commands"))
1626        );
1627    }
1628
1629    #[test]
1630    fn unassigned_legacy_scope_is_read_only() {
1631        let mut session = Session::new("legacy-session", "legacy");
1632        session.set_workspace_path_meta("/tmp/legacy-workspace");
1633        assert!(ProjectContextResolver::memory_read_scope_for_session(&session).is_some());
1634        assert!(ProjectContextResolver::memory_write_scope_for_session(&session).is_none());
1635    }
1636}