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