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