Skip to main content

bamboo_engine/
project_context.rs

1//! Stable Project identity and shared-resource resolution.
2//!
3//! A Project is not a workspace.  The Project id carried by a session is the
4//! authority for memory and shared resources; the workspace is only the
5//! mutable filesystem execution context.  This module is the single engine
6//! seam for resolving those two identities together.
7
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use bamboo_agent_core::Session;
13use bamboo_domain::{ProjectId, ProjectResourceKind, ProjectResourceSummary, WorkspaceBinding};
14use serde::{Deserialize, Serialize};
15
16pub const PROJECT_ID_METADATA_KEY: &str = "project_id";
17pub const PROJECT_RESOURCES_RENDERED_KEY: &str = "project_resources_rendered";
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum WorkspaceBindingStatus {
22    Registered,
23    Unregistered,
24}
25
26impl WorkspaceBindingStatus {
27    pub const fn as_str(self) -> &'static str {
28        match self {
29            Self::Registered => "registered",
30            Self::Unregistered => "unregistered",
31        }
32    }
33}
34
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct ProjectDescriptor {
37    pub id: ProjectId,
38    pub name: String,
39    pub home: PathBuf,
40    pub workspace_bindings: Vec<WorkspaceBinding>,
41    pub resources: ProjectResourceSummary,
42    pub memory_read_roots: ProjectMemoryReadRoots,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct ProjectMemoryReadRoots {
47    pub primary: PathBuf,
48    pub legacy_aliases: Vec<bamboo_memory::memory_store::LegacyProjectMemoryReadRoot>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct ResolvedProjectContext {
53    pub project: ProjectDescriptor,
54    pub workspace: Option<PathBuf>,
55    pub binding_status: WorkspaceBindingStatus,
56}
57
58impl ResolvedProjectContext {
59    pub fn resource_scope(&self) -> ProjectResourceScope {
60        ProjectResourceScope {
61            project_id: self.project.id.clone(),
62            project_home: self.project.home.clone(),
63            workspace: self.workspace.clone(),
64            binding_status: self.binding_status,
65            resource_revision: self.project.resources.resource_revision,
66        }
67    }
68
69    pub fn render_resource_inventory(&self) -> String {
70        let mut entries = self.project.resources.resources.clone();
71        entries.sort_by_key(|entry| entry.kind);
72        let rendered = entries
73            .into_iter()
74            .map(|entry| {
75                format!(
76                    "- {:?}: status={}, items={}",
77                    entry.kind,
78                    if entry.present { "available" } else { "empty" },
79                    entry.item_count
80                )
81            })
82            .collect::<Vec<_>>()
83            .join("\n");
84        format!(
85            "Project ID: {}\nResource revision: {}\n{}",
86            self.project.id,
87            self.project.resources.resource_revision,
88            if rendered.is_empty() {
89                "No Project-shared resources are currently advertised.".to_string()
90            } else {
91                rendered
92            }
93        )
94    }
95}
96
97#[derive(Debug, thiserror::Error)]
98pub enum ProjectContextError {
99    #[error("project context source failed: {0}")]
100    Source(String),
101    #[error("project context source returned '{actual}' for requested project '{requested}'")]
102    IdentityMismatch { requested: String, actual: String },
103    #[error(
104        "workspace '{workspace}' belongs to Project '{owner_project_id}', not session Project '{session_project_id}'"
105    )]
106    WorkspaceConflict {
107        workspace: String,
108        owner_project_id: ProjectId,
109        session_project_id: ProjectId,
110    },
111    #[error("workspace '{workspace}' belongs to Project '{owner_project_id}', but the session is Unassigned")]
112    UnassignedWorkspaceConflict {
113        workspace: String,
114        owner_project_id: ProjectId,
115    },
116    #[error("session carries an invalid Project identity '{raw}': {message}")]
117    InvalidProjectIdentity { raw: String, message: String },
118    #[error("assigned Project '{project_id}' is unavailable")]
119    ProjectUnavailable { project_id: ProjectId },
120    #[error("workspace '{workspace}' is invalid: {message}")]
121    WorkspaceInvalid { workspace: String, message: String },
122}
123
124/// Adapter implemented by the authoritative Project registry.
125///
126/// The engine deliberately depends on this redacted descriptor rather than on
127/// registry persistence details. Secret settings and credential values must
128/// never be added to this interface.
129#[async_trait]
130pub trait ProjectContextSource: Send + Sync {
131    async fn find_project(
132        &self,
133        project_id: &ProjectId,
134    ) -> Result<Option<ProjectDescriptor>, ProjectContextError>;
135
136    async fn list_projects(&self) -> Result<Vec<ProjectDescriptor>, ProjectContextError> {
137        Ok(Vec::new())
138    }
139
140    /// Resolve the global Project owner for an exact workspace. Sources that
141    /// cannot provide a registry-wide answer remain backward compatible.
142    async fn find_workspace_owner(
143        &self,
144        _workspace: &Path,
145    ) -> Result<Option<ProjectId>, ProjectContextError> {
146        Ok(None)
147    }
148}
149
150#[derive(Clone)]
151pub struct ProjectContextResolver {
152    source: Arc<dyn ProjectContextSource>,
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
156pub enum SessionProjectIdentity {
157    Unassigned,
158    Assigned(ProjectId),
159    Invalid { raw: String, message: String },
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
163pub enum ProjectMemoryScope {
164    Assigned {
165        project_id: ProjectId,
166        legacy_aliases: Vec<bamboo_memory::memory_store::LegacyProjectMemoryReadRoot>,
167    },
168    LegacyReadOnly(String),
169}
170
171impl ProjectMemoryScope {
172    pub fn key(&self) -> &str {
173        match self {
174            Self::Assigned { project_id, .. } => project_id.as_str(),
175            Self::LegacyReadOnly(project_key) => project_key,
176        }
177    }
178
179    pub fn scoped_store(
180        &self,
181        store: &bamboo_memory::memory_store::MemoryStore,
182    ) -> bamboo_memory::memory_store::MemoryStore {
183        match self {
184            Self::Assigned {
185                project_id,
186                legacy_aliases,
187            } => store.for_project_with_legacy_read_roots(project_id, legacy_aliases.clone()),
188            Self::LegacyReadOnly(_) => store.clone(),
189        }
190    }
191}
192
193impl ProjectContextResolver {
194    pub fn new(source: Arc<dyn ProjectContextSource>) -> Self {
195        Self { source }
196    }
197
198    /// Return the stable, opaque Project id persisted on the session.
199    ///
200    /// The domain accessor dual-writes this compatibility key. Keeping the
201    /// read centralized here prevents memory, Dream, prompt, and resource
202    /// callers from independently falling back to mutable workspace identity.
203    pub fn project_id_from_session(session: &Session) -> Option<ProjectId> {
204        match Self::session_project_identity(session) {
205            SessionProjectIdentity::Assigned(project_id) => Some(project_id),
206            SessionProjectIdentity::Invalid { raw, message } => {
207                tracing::warn!(
208                    session_id = %session.id,
209                    "ignoring invalid persisted Project id '{raw}': {message}"
210                );
211                None
212            }
213            SessionProjectIdentity::Unassigned => None,
214        }
215    }
216
217    /// Parse persisted Project membership into an authoritative three-state
218    /// value. Whitespace is normalized exactly like the rebuildable storage
219    /// index. Callers with security/resource consequences must distinguish
220    /// `Invalid` from truly `Unassigned`.
221    pub fn session_project_identity(session: &Session) -> SessionProjectIdentity {
222        let Some(raw) = session.project_id_meta() else {
223            return SessionProjectIdentity::Unassigned;
224        };
225        let normalized = raw.trim();
226        match ProjectId::parse(normalized) {
227            Ok(project_id) => SessionProjectIdentity::Assigned(project_id),
228            Err(error) => SessionProjectIdentity::Invalid {
229                raw,
230                message: error.to_string(),
231            },
232        }
233    }
234
235    /// Resolve the Project id used for memory reads.
236    ///
237    /// Assigned sessions always use their stable Project id. The path-derived
238    /// fallback is read-compatibility for unassigned legacy sessions only; new
239    /// sessions and writes must use [`Self::project_id_from_session`].
240    pub fn memory_read_scope_for_session(session: &Session) -> Option<String> {
241        Self::memory_read_identity_for_session(session).map(|scope| scope.key().to_string())
242    }
243
244    pub fn memory_read_identity_for_session(session: &Session) -> Option<ProjectMemoryScope> {
245        match Self::session_project_identity(session) {
246            SessionProjectIdentity::Assigned(project_id) => Some(ProjectMemoryScope::Assigned {
247                project_id,
248                legacy_aliases: Vec::new(),
249            }),
250            SessionProjectIdentity::Unassigned => session
251                .workspace_path_meta()
252                .map(PathBuf::from)
253                .or_else(|| {
254                    bamboo_tools::tools::workspace_state::get_workspace(session.id.as_str())
255                })
256                .map(|path| {
257                    ProjectMemoryScope::LegacyReadOnly(
258                        bamboo_memory::memory_store::project_key_from_path(&path),
259                    )
260                }),
261            SessionProjectIdentity::Invalid { .. } => None,
262        }
263    }
264
265    pub async fn resolve_memory_read_scope(
266        &self,
267        session: &Session,
268        workspace: Option<&Path>,
269    ) -> Result<Option<ProjectMemoryScope>, ProjectContextError> {
270        match Self::session_project_identity(session) {
271            SessionProjectIdentity::Unassigned => {
272                return Ok(Self::memory_read_identity_for_session(session));
273            }
274            SessionProjectIdentity::Invalid { raw, message } => {
275                return Err(ProjectContextError::InvalidProjectIdentity { raw, message });
276            }
277            SessionProjectIdentity::Assigned(_) => {}
278        }
279        Ok(self
280            .resolve(session, workspace)
281            .await?
282            .map(|context| ProjectMemoryScope::Assigned {
283                project_id: context.project.id,
284                legacy_aliases: context.project.memory_read_roots.legacy_aliases,
285            }))
286    }
287
288    pub async fn list_memory_read_scopes(
289        &self,
290    ) -> Result<Vec<ProjectMemoryScope>, ProjectContextError> {
291        Ok(self
292            .source
293            .list_projects()
294            .await?
295            .into_iter()
296            .map(|project| ProjectMemoryScope::Assigned {
297                project_id: project.id,
298                legacy_aliases: project.memory_read_roots.legacy_aliases,
299            })
300            .collect())
301    }
302
303    /// Resolve the only valid Project write scope.
304    ///
305    /// Unassigned legacy sessions intentionally return `None`: their
306    /// path-derived scopes are read/migration aliases and must never receive
307    /// new Project memory or Dream writes.
308    pub fn memory_write_scope_for_session(session: &Session) -> Option<String> {
309        match Self::session_project_identity(session) {
310            SessionProjectIdentity::Assigned(project_id) => Some(project_id.into_string()),
311            SessionProjectIdentity::Unassigned | SessionProjectIdentity::Invalid { .. } => None,
312        }
313    }
314
315    pub async fn resolve(
316        &self,
317        session: &Session,
318        workspace: Option<&Path>,
319    ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
320        let workspace = Self::resolve_workspace_candidate(session, workspace)?;
321        self.resolve_with_final_workspace(session, workspace).await
322    }
323
324    /// Resolve the exact workspace the runtime will use without publishing it.
325    ///
326    /// This is shared by HTTP preflight, SDK/execute prompt refresh, and the
327    /// Workspace tool so configured/session-default fallbacks cannot bypass
328    /// Project ownership checks.
329    pub fn resolve_workspace_candidate(
330        session: &Session,
331        workspace: Option<&Path>,
332    ) -> Result<Option<PathBuf>, ProjectContextError> {
333        let preferred = workspace
334            .map(Path::to_path_buf)
335            .or_else(|| session.workspace_path_meta().map(PathBuf::from));
336        bamboo_agent_core::workspace_state::resolve_session_workspace_candidate(
337            &session.id,
338            preferred,
339        )
340        .map(|candidate| resolve_final_workspace(&candidate))
341        .transpose()
342    }
343
344    async fn resolve_with_final_workspace(
345        &self,
346        session: &Session,
347        workspace: Option<PathBuf>,
348    ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
349        let project_id = match Self::session_project_identity(session) {
350            SessionProjectIdentity::Assigned(project_id) => project_id,
351            SessionProjectIdentity::Invalid { raw, message } => {
352                return Err(ProjectContextError::InvalidProjectIdentity { raw, message });
353            }
354            SessionProjectIdentity::Unassigned => {
355                if let Some(candidate) = workspace.as_deref() {
356                    if let Some(owner_project_id) =
357                        self.source.find_workspace_owner(candidate).await?
358                    {
359                        return Err(ProjectContextError::UnassignedWorkspaceConflict {
360                            workspace: candidate.to_string_lossy().into_owned(),
361                            owner_project_id,
362                        });
363                    }
364                }
365                return Ok(None);
366            }
367        };
368        let project = self
369            .source
370            .find_project(&project_id)
371            .await?
372            .ok_or_else(|| ProjectContextError::ProjectUnavailable {
373                project_id: project_id.clone(),
374            })?;
375        if project.id != project_id {
376            return Err(ProjectContextError::IdentityMismatch {
377                requested: project_id.to_string(),
378                actual: project.id.to_string(),
379            });
380        }
381
382        let binding_status = match workspace.as_deref() {
383            Some(candidate) => match self.source.find_workspace_owner(candidate).await? {
384                Some(owner) if owner == project.id => WorkspaceBindingStatus::Registered,
385                Some(owner) => {
386                    return Err(ProjectContextError::WorkspaceConflict {
387                        workspace: candidate.to_string_lossy().into_owned(),
388                        owner_project_id: owner,
389                        session_project_id: project.id.clone(),
390                    });
391                }
392                None if project
393                    .workspace_bindings
394                    .iter()
395                    .any(|binding| path_is_within_binding(Path::new(&binding.path), candidate)) =>
396                {
397                    WorkspaceBindingStatus::Registered
398                }
399                None => WorkspaceBindingStatus::Unregistered,
400            },
401            None => WorkspaceBindingStatus::Unregistered,
402        };
403
404        Ok(Some(ResolvedProjectContext {
405            project,
406            workspace,
407            binding_status,
408        }))
409    }
410
411    pub async fn workspace_owner(
412        &self,
413        workspace: &Path,
414    ) -> Result<Option<ProjectId>, ProjectContextError> {
415        self.source.find_workspace_owner(workspace).await
416    }
417
418    /// Resolve and persist the stable Project and mutable Workspace prompt
419    /// markers immediately.
420    ///
421    /// Session-create and chat APIs call this before their first response so a
422    /// freshly-created assigned session is already self-describing when read
423    /// back, rather than waiting for the first execution round. The round
424    /// prelude calls the same helper to keep the markers current.
425    pub async fn refresh_session_prompt(
426        &self,
427        session: &mut Session,
428    ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
429        self.refresh_session_prompt_inner(session, true).await
430    }
431
432    /// Resolve Project/Workspace prompt markers on an in-memory snapshot
433    /// without changing runtime workspace state.
434    ///
435    /// Read APIs use this for sessions that have never entered the runner
436    /// (for example a disabled schedule or a child created with
437    /// `auto_run=false`). The caller is expected to discard the temporary
438    /// session after building the response.
439    pub async fn refresh_session_prompt_read_only(
440        &self,
441        session: &mut Session,
442    ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
443        self.refresh_session_prompt_inner(session, false).await
444    }
445
446    async fn refresh_session_prompt_inner(
447        &self,
448        session: &mut Session,
449        sync_runtime_workspace: bool,
450    ) -> Result<Option<ResolvedProjectContext>, ProjectContextError> {
451        let workspace = Self::resolve_workspace_candidate(session, None)?;
452        let resolved = self
453            .resolve_with_final_workspace(session, workspace.clone())
454            .await?;
455        if let Some(workspace) = workspace.as_deref() {
456            let final_workspace = if sync_runtime_workspace {
457                bamboo_tools::tools::workspace_state::publish_resolved_workspace(
458                    &session.id,
459                    workspace.into(),
460                )
461            } else {
462                workspace.to_path_buf()
463            };
464            session.set_workspace_path_meta(bamboo_config::paths::path_to_display_string(
465                &final_workspace,
466            ));
467        }
468        let current = session
469            .messages
470            .iter()
471            .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
472            .map(|message| message.content.clone())
473            .or_else(|| session.metadata.get("base_system_prompt").cloned())
474            .unwrap_or_default();
475        let mut updated =
476            crate::runtime::context::upsert_project_prompt_context(&current, resolved.as_ref());
477
478        if let Some(context) = resolved.as_ref() {
479            session.metadata.insert(
480                PROJECT_RESOURCES_RENDERED_KEY.to_string(),
481                context.render_resource_inventory(),
482            );
483        } else {
484            session.metadata.remove(PROJECT_RESOURCES_RENDERED_KEY);
485        }
486        let workspace_display = workspace
487            .as_deref()
488            .map(bamboo_config::paths::path_to_display_string);
489        updated = crate::runtime::context::upsert_workspace_prompt_context(
490            &updated,
491            workspace_display.as_deref(),
492            resolved
493                .as_ref()
494                .map(|context| context.binding_status)
495                .unwrap_or(WorkspaceBindingStatus::Unregistered),
496        );
497
498        if let Some(system_message) = session
499            .messages
500            .iter_mut()
501            .find(|message| matches!(message.role, bamboo_agent_core::Role::System))
502        {
503            system_message.content = updated;
504        } else if !updated.trim().is_empty() {
505            session
506                .messages
507                .insert(0, bamboo_agent_core::Message::system(updated));
508        }
509        crate::runner::refresh_prompt_snapshot(session);
510
511        Ok(resolved)
512    }
513}
514
515fn resolve_final_workspace(workspace: &Path) -> Result<PathBuf, ProjectContextError> {
516    if workspace.exists() && !workspace.is_dir() {
517        return Err(ProjectContextError::WorkspaceInvalid {
518            workspace: workspace.to_string_lossy().into_owned(),
519            message: "path is not a directory".to_string(),
520        });
521    }
522    let canonical = std::fs::canonicalize(workspace).unwrap_or_else(|_| workspace.to_path_buf());
523    let final_workspace = bamboo_agent_core::workspace_state::preview_workspace_path(canonical);
524    if final_workspace.exists() && !final_workspace.is_dir() {
525        return Err(ProjectContextError::WorkspaceInvalid {
526            workspace: final_workspace.to_string_lossy().into_owned(),
527            message: "resolved path is not a directory".to_string(),
528        });
529    }
530    Ok(std::fs::canonicalize(&final_workspace).unwrap_or(final_workspace))
531}
532
533fn path_is_within_binding(binding: &Path, candidate: &Path) -> bool {
534    match (
535        std::fs::canonicalize(binding),
536        std::fs::canonicalize(candidate),
537    ) {
538        (Ok(binding), Ok(candidate)) => candidate == binding || candidate.starts_with(binding),
539        _ => candidate == binding || candidate.starts_with(binding),
540    }
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
544#[serde(rename_all = "snake_case")]
545pub enum ProjectResourceLayer {
546    Project,
547    Workspace,
548}
549
550#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
551pub struct ProjectResourceCandidate {
552    pub kind: ProjectResourceKind,
553    pub layer: ProjectResourceLayer,
554    pub path: PathBuf,
555    pub exists: bool,
556}
557
558#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
559pub struct ProjectResourceDiagnostic {
560    pub project_id: ProjectId,
561    pub resource_revision: u64,
562    pub workspace_binding_status: WorkspaceBindingStatus,
563    pub candidates: Vec<ProjectResourceCandidate>,
564}
565
566/// Stable Project-home resources plus the current workspace overlay.
567#[derive(Debug, Clone, PartialEq, Eq)]
568pub struct ProjectResourceScope {
569    pub project_id: ProjectId,
570    pub project_home: PathBuf,
571    pub workspace: Option<PathBuf>,
572    pub binding_status: WorkspaceBindingStatus,
573    pub resource_revision: u64,
574}
575
576impl ProjectResourceScope {
577    pub fn project_memory_root(&self) -> PathBuf {
578        self.project_home.join("memory").join("v1")
579    }
580
581    pub fn project_skills_dir(&self) -> PathBuf {
582        self.project_home.join("skills")
583    }
584
585    pub fn project_mode_skills_dir(&self, mode: &str) -> PathBuf {
586        self.project_home.join(format!("skills-{mode}"))
587    }
588
589    pub fn workspace_skills_dir(&self) -> Option<PathBuf> {
590        self.workspace
591            .as_ref()
592            .map(|workspace| workspace.join(".bamboo").join("skills"))
593    }
594
595    pub fn workspace_mode_skills_dir(&self, mode: &str) -> Option<PathBuf> {
596        self.workspace
597            .as_ref()
598            .map(|workspace| workspace.join(".bamboo").join(format!("skills-{mode}")))
599    }
600
601    pub fn project_commands_dir(&self) -> PathBuf {
602        self.project_home.join("commands")
603    }
604
605    pub fn workspace_commands_dir(&self) -> Option<PathBuf> {
606        let workspace = self.workspace.as_deref()?;
607        let boundary = nearest_git_boundary(workspace).unwrap_or_else(|| workspace.to_path_buf());
608        Some(boundary.join(".bamboo").join("commands"))
609    }
610
611    /// Ordinary resource precedence is Project first, then the more-specific
612    /// workspace overlay. Security policies must use their dedicated managed /
613    /// deny / trust merge logic instead of this shadowing order.
614    pub fn candidates(&self, kind: ProjectResourceKind) -> Vec<ProjectResourceCandidate> {
615        let mut paths = match kind {
616            ProjectResourceKind::Settings => vec![(
617                ProjectResourceLayer::Project,
618                self.project_home.join("settings.json"),
619            )],
620            ProjectResourceKind::Memory => {
621                vec![(ProjectResourceLayer::Project, self.project_memory_root())]
622            }
623            ProjectResourceKind::Skills => {
624                let mut values = vec![(ProjectResourceLayer::Project, self.project_skills_dir())];
625                if let Some(path) = self.workspace_skills_dir() {
626                    values.push((ProjectResourceLayer::Workspace, path));
627                }
628                values
629            }
630            ProjectResourceKind::Commands => {
631                let mut values = vec![(ProjectResourceLayer::Project, self.project_commands_dir())];
632                if let Some(path) = self.workspace_commands_dir() {
633                    values.push((ProjectResourceLayer::Workspace, path));
634                }
635                values
636            }
637            ProjectResourceKind::Artifacts => vec![(
638                ProjectResourceLayer::Project,
639                self.project_home.join("artifacts"),
640            )],
641            ProjectResourceKind::State => vec![(
642                ProjectResourceLayer::Project,
643                self.project_home.join("state"),
644            )],
645        };
646
647        paths
648            .drain(..)
649            .map(|(layer, path)| ProjectResourceCandidate {
650                kind,
651                layer,
652                exists: path.exists(),
653                path,
654            })
655            .collect()
656    }
657
658    pub fn diagnostic(&self) -> ProjectResourceDiagnostic {
659        let mut candidates = Vec::new();
660        for kind in [
661            ProjectResourceKind::Settings,
662            ProjectResourceKind::Memory,
663            ProjectResourceKind::Skills,
664            ProjectResourceKind::Commands,
665            ProjectResourceKind::Artifacts,
666            ProjectResourceKind::State,
667        ] {
668            candidates.extend(self.candidates(kind));
669        }
670        ProjectResourceDiagnostic {
671            project_id: self.project_id.clone(),
672            resource_revision: self.resource_revision,
673            workspace_binding_status: self.binding_status,
674            candidates,
675        }
676    }
677}
678
679fn nearest_git_boundary(start: &Path) -> Option<PathBuf> {
680    start
681        .ancestors()
682        .find(|candidate| {
683            let git = candidate.join(".git");
684            git.is_dir() || git.is_file()
685        })
686        .map(Path::to_path_buf)
687}
688
689#[cfg(test)]
690mod tests {
691    use super::*;
692
693    struct StaticSource(ProjectDescriptor);
694
695    #[async_trait]
696    impl ProjectContextSource for StaticSource {
697        async fn find_project(
698            &self,
699            project_id: &ProjectId,
700        ) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
701            Ok((&self.0.id == project_id).then(|| self.0.clone()))
702        }
703    }
704
705    struct OwnedWorkspaceSource {
706        descriptor: ProjectDescriptor,
707        owner: ProjectId,
708    }
709
710    #[async_trait]
711    impl ProjectContextSource for OwnedWorkspaceSource {
712        async fn find_project(
713            &self,
714            project_id: &ProjectId,
715        ) -> Result<Option<ProjectDescriptor>, ProjectContextError> {
716            Ok((&self.descriptor.id == project_id).then(|| self.descriptor.clone()))
717        }
718
719        async fn find_workspace_owner(
720            &self,
721            _workspace: &Path,
722        ) -> Result<Option<ProjectId>, ProjectContextError> {
723            Ok(Some(self.owner.clone()))
724        }
725    }
726
727    #[tokio::test]
728    async fn workspace_changes_do_not_change_project_identity_or_home() {
729        let directory = tempfile::tempdir().expect("tempdir");
730        let main = directory.path().join("main");
731        let worktree = directory.path().join("worktree");
732        std::fs::create_dir_all(&main).expect("main");
733        std::fs::create_dir_all(&worktree).expect("worktree");
734        let project_id = ProjectId::parse("01JPROJECT00000000000000000").expect("project id");
735        let descriptor = ProjectDescriptor {
736            id: project_id.clone(),
737            name: "Zenith".to_string(),
738            home: directory
739                .path()
740                .join("projects/01JPROJECT00000000000000000"),
741            workspace_bindings: vec![
742                WorkspaceBinding {
743                    path: main.to_string_lossy().to_string(),
744                    label: Some("main".to_string()),
745                    git_common_dir: None,
746                },
747                WorkspaceBinding {
748                    path: worktree.to_string_lossy().to_string(),
749                    label: Some("worktree".to_string()),
750                    git_common_dir: None,
751                },
752            ],
753            resources: bamboo_domain::ProjectResourceSummary {
754                project_id: project_id.clone(),
755                resource_revision: 7,
756                resources: Vec::new(),
757            },
758            memory_read_roots: ProjectMemoryReadRoots {
759                primary: directory
760                    .path()
761                    .join("projects/01JPROJECT00000000000000000/memory/v1"),
762                legacy_aliases: Vec::new(),
763            },
764        };
765        let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
766        let mut session = Session::new("session-1", "test");
767        session.set_project_id_meta(project_id.to_string());
768
769        let first = resolver
770            .resolve(&session, Some(&main))
771            .await
772            .expect("resolve")
773            .expect("assigned");
774        let second = resolver
775            .resolve(&session, Some(&worktree))
776            .await
777            .expect("resolve")
778            .expect("assigned");
779        assert_eq!(first.project.id, second.project.id);
780        assert_eq!(first.project.home, second.project.home);
781        assert_eq!(second.binding_status, WorkspaceBindingStatus::Registered);
782    }
783
784    #[tokio::test]
785    async fn prompt_refresh_removes_stale_project_and_updates_unassigned_workspace() {
786        let directory = tempfile::tempdir().expect("tempdir");
787        let workspace = directory.path().join("workspace");
788        std::fs::create_dir_all(&workspace).expect("workspace");
789        let project_id = ProjectId::parse("project-prompt-refresh").expect("Project id");
790        let descriptor = ProjectDescriptor {
791            id: project_id.clone(),
792            name: "Prompt Project".to_string(),
793            home: directory.path().join("projects/project-prompt-refresh"),
794            workspace_bindings: vec![WorkspaceBinding {
795                path: workspace.to_string_lossy().into_owned(),
796                label: None,
797                git_common_dir: None,
798            }],
799            resources: bamboo_domain::ProjectResourceSummary {
800                project_id: project_id.clone(),
801                resource_revision: 1,
802                resources: Vec::new(),
803            },
804            memory_read_roots: ProjectMemoryReadRoots {
805                primary: directory
806                    .path()
807                    .join("projects/project-prompt-refresh/memory/v1"),
808                legacy_aliases: Vec::new(),
809            },
810        };
811        let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
812        let mut session = Session::new("prompt-project-switch", "test");
813        session
814            .messages
815            .insert(0, bamboo_agent_core::Message::system("base"));
816        session.set_project_id_meta(project_id.to_string());
817        session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
818
819        resolver
820            .refresh_session_prompt(&mut session)
821            .await
822            .expect("assigned refresh");
823        let assigned = &session.messages[0].content;
824        assert_eq!(assigned.matches("BAMBOO_PROJECT_CONTEXT_START").count(), 1);
825        assert!(assigned.contains("Binding status: registered"));
826
827        session.clear_project_id_meta();
828        resolver
829            .refresh_session_prompt(&mut session)
830            .await
831            .expect("unassigned refresh");
832        let unassigned = &session.messages[0].content;
833        assert_eq!(
834            unassigned.matches("BAMBOO_PROJECT_CONTEXT_START").count(),
835            0
836        );
837        assert_eq!(
838            unassigned.matches("BAMBOO_WORKSPACE_CONTEXT_START").count(),
839            1
840        );
841        assert!(unassigned.contains("Binding status: unregistered"));
842        assert!(!session
843            .metadata
844            .contains_key(PROJECT_RESOURCES_RENDERED_KEY));
845    }
846
847    #[tokio::test]
848    async fn workspace_owned_by_another_project_fails_closed() {
849        let directory = tempfile::tempdir().expect("tempdir");
850        let workspace = directory.path().join("workspace");
851        std::fs::create_dir_all(&workspace).expect("workspace");
852        let project_id = ProjectId::parse("project-a").expect("project id");
853        let owner = ProjectId::parse("project-b").expect("owner id");
854        let descriptor = ProjectDescriptor {
855            id: project_id.clone(),
856            name: "Project A".to_string(),
857            home: directory.path().join("projects/project-a"),
858            workspace_bindings: vec![WorkspaceBinding {
859                path: workspace.to_string_lossy().into_owned(),
860                label: None,
861                git_common_dir: None,
862            }],
863            resources: bamboo_domain::ProjectResourceSummary {
864                project_id: project_id.clone(),
865                resource_revision: 1,
866                resources: Vec::new(),
867            },
868            memory_read_roots: ProjectMemoryReadRoots {
869                primary: directory.path().join("projects/project-a/memory/v1"),
870                legacy_aliases: Vec::new(),
871            },
872        };
873        let resolver = ProjectContextResolver::new(Arc::new(OwnedWorkspaceSource {
874            descriptor,
875            owner: owner.clone(),
876        }));
877        let mut session = Session::new("session-conflict", "test");
878        session.set_project_id_meta(project_id.to_string());
879
880        let error = resolver
881            .resolve(&session, Some(&workspace))
882            .await
883            .expect_err("cross-Project workspace must fail closed");
884        assert!(matches!(
885            error,
886            ProjectContextError::WorkspaceConflict {
887                owner_project_id,
888                session_project_id,
889                ..
890            } if owner_project_id == owner && session_project_id == project_id
891        ));
892
893        let safe_workspace = directory.path().join("safe");
894        std::fs::create_dir_all(&safe_workspace).expect("safe workspace");
895        let safe_canonical = safe_workspace.canonicalize().expect("canonical safe");
896        bamboo_tools::tools::workspace_state::set_workspace(&session.id, safe_canonical.clone());
897        session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
898        let error = resolver
899            .refresh_session_prompt(&mut session)
900            .await
901            .expect_err("refresh must validate before publishing workspace");
902        assert!(matches!(
903            error,
904            ProjectContextError::WorkspaceConflict { .. }
905        ));
906        assert_eq!(
907            bamboo_tools::tools::workspace_state::get_workspace(&session.id).as_deref(),
908            Some(safe_canonical.as_path()),
909            "a post-preflight ownership change must not publish the rejected workspace"
910        );
911    }
912
913    #[tokio::test]
914    async fn malformed_project_identity_never_falls_back_to_legacy_workspace_scope() {
915        let directory = tempfile::tempdir().expect("tempdir");
916        let workspace = directory.path().join("workspace");
917        std::fs::create_dir_all(&workspace).expect("workspace");
918        let descriptor_id = ProjectId::parse("descriptor").expect("Project id");
919        let descriptor = ProjectDescriptor {
920            id: descriptor_id.clone(),
921            name: "Descriptor".to_string(),
922            home: directory.path().join("projects/descriptor"),
923            workspace_bindings: Vec::new(),
924            resources: bamboo_domain::ProjectResourceSummary {
925                project_id: descriptor_id.clone(),
926                resource_revision: 1,
927                resources: Vec::new(),
928            },
929            memory_read_roots: ProjectMemoryReadRoots {
930                primary: directory.path().join("projects/descriptor/memory/v1"),
931                legacy_aliases: Vec::new(),
932            },
933        };
934        let resolver = ProjectContextResolver::new(Arc::new(StaticSource(descriptor)));
935        let mut session = Session::new("malformed", "test");
936        session.set_project_id_meta("../malformed");
937        session.set_workspace_path_meta(workspace.to_string_lossy().into_owned());
938
939        assert!(ProjectContextResolver::memory_read_identity_for_session(&session).is_none());
940        assert!(matches!(
941            resolver.resolve(&session, Some(&workspace)).await,
942            Err(ProjectContextError::InvalidProjectIdentity { .. })
943        ));
944        assert!(matches!(
945            resolver
946                .resolve_memory_read_scope(&session, Some(&workspace))
947                .await,
948            Err(ProjectContextError::InvalidProjectIdentity { .. })
949        ));
950    }
951
952    #[tokio::test]
953    async fn unassigned_session_cannot_resolve_a_project_owned_workspace() {
954        let directory = tempfile::tempdir().expect("tempdir");
955        let workspace = directory.path().join("workspace");
956        std::fs::create_dir_all(&workspace).expect("workspace");
957        let descriptor_id = ProjectId::parse("descriptor").expect("Project id");
958        let owner = ProjectId::parse("owner").expect("owner Project id");
959        let descriptor = ProjectDescriptor {
960            id: descriptor_id.clone(),
961            name: "Descriptor".to_string(),
962            home: directory.path().join("projects/descriptor"),
963            workspace_bindings: Vec::new(),
964            resources: bamboo_domain::ProjectResourceSummary {
965                project_id: descriptor_id,
966                resource_revision: 1,
967                resources: Vec::new(),
968            },
969            memory_read_roots: ProjectMemoryReadRoots {
970                primary: directory.path().join("projects/descriptor/memory/v1"),
971                legacy_aliases: Vec::new(),
972            },
973        };
974        let resolver = ProjectContextResolver::new(Arc::new(OwnedWorkspaceSource {
975            descriptor,
976            owner: owner.clone(),
977        }));
978        let session = Session::new("unassigned", "test");
979
980        assert!(matches!(
981            resolver.resolve(&session, Some(&workspace)).await,
982            Err(ProjectContextError::UnassignedWorkspaceConflict {
983                owner_project_id,
984                ..
985            }) if owner_project_id == owner
986        ));
987    }
988
989    #[test]
990    fn project_identity_parser_trims_like_the_storage_index() {
991        let mut session = Session::new("whitespace", "test");
992        session.set_project_id_meta("  project-1  ");
993        assert_eq!(
994            ProjectContextResolver::session_project_identity(&session),
995            SessionProjectIdentity::Assigned(ProjectId::parse("project-1").unwrap())
996        );
997    }
998
999    #[test]
1000    fn resource_diagnostic_distinguishes_project_and_workspace_layers() {
1001        let directory = tempfile::tempdir().expect("tempdir");
1002        let project_home = directory.path().join("project-home");
1003        let workspace = directory.path().join("workspace");
1004        std::fs::create_dir_all(project_home.join("skills")).expect("project skills");
1005        std::fs::create_dir_all(workspace.join(".bamboo/skills")).expect("workspace skills");
1006        let scope = ProjectResourceScope {
1007            project_id: ProjectId::parse("project-1").expect("project id"),
1008            project_home,
1009            workspace: Some(workspace),
1010            binding_status: WorkspaceBindingStatus::Registered,
1011            resource_revision: 4,
1012        };
1013
1014        let candidates = scope.candidates(ProjectResourceKind::Skills);
1015        assert_eq!(candidates.len(), 2);
1016        assert_eq!(candidates[0].layer, ProjectResourceLayer::Project);
1017        assert_eq!(candidates[1].layer, ProjectResourceLayer::Workspace);
1018        assert!(candidates.iter().all(|candidate| candidate.exists));
1019    }
1020
1021    #[test]
1022    fn workspace_commands_resolve_from_nearest_git_boundary() {
1023        let directory = tempfile::tempdir().expect("tempdir");
1024        let repository = directory.path().join("repo");
1025        let nested = repository.join("nested/path");
1026        std::fs::create_dir_all(repository.join(".git")).expect("git");
1027        std::fs::create_dir_all(&nested).expect("nested");
1028        let scope = ProjectResourceScope {
1029            project_id: ProjectId::parse("project-1").expect("project id"),
1030            project_home: directory.path().join("project-home"),
1031            workspace: Some(nested),
1032            binding_status: WorkspaceBindingStatus::Registered,
1033            resource_revision: 1,
1034        };
1035        assert_eq!(
1036            scope.workspace_commands_dir(),
1037            Some(repository.join(".bamboo/commands"))
1038        );
1039    }
1040
1041    #[test]
1042    fn unassigned_legacy_scope_is_read_only() {
1043        let mut session = Session::new("legacy-session", "legacy");
1044        session.set_workspace_path_meta("/tmp/legacy-workspace");
1045        assert!(ProjectContextResolver::memory_read_scope_for_session(&session).is_some());
1046        assert!(ProjectContextResolver::memory_write_scope_for_session(&session).is_none());
1047    }
1048}