La surface « assistant de ticket » imposait un SessionPlan Landlock restrictif (project_read_only) qui gouvernait la classe exec de la sandbox OS et empêchait le spawn de la CLI (EACCES / os error 13). factory.start reçoit désormais SessionPlan::None + sandbox absent : l'assistant n'impose plus ce plan OS-sandbox. La classe exec n'est plus verrouillée, le spawn de la CLI aboutit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
345 lines
9.3 KiB
Rust
345 lines
9.3 KiB
Rust
use std::collections::HashMap;
|
|
use std::str::FromStr;
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use application::{
|
|
CloseTicketAssistant, CloseTicketAssistantInput, OpenTicketAssistant, OpenTicketAssistantInput,
|
|
StructuredSessions,
|
|
};
|
|
use async_trait::async_trait;
|
|
use domain::ports::{
|
|
AgentSession, AgentSessionError, AgentSessionFactory, ReplyStream, SessionPlan,
|
|
};
|
|
use domain::profile::StructuredAdapter;
|
|
use domain::{
|
|
AgentProfile, AgentToolPolicy, AgentToolPolicyStore, AssistantContextError,
|
|
AssistantContextProvider, ContextInjection, DomainEvent, EventBus, EventStream, Issue,
|
|
IssueActor, IssueCarnet, IssueId, IssueListFilter, IssueNumber, IssuePriority, IssueRef,
|
|
IssueStatus, IssueStore, IssueStoreError, IssueVersion, MarkdownDoc, PreparedContext,
|
|
ProfileId, ProfileStore, Project, ProjectId, ProjectPath, RemoteRef, SessionId, StoreError,
|
|
};
|
|
use uuid::Uuid;
|
|
|
|
fn issue_ref(raw: &str) -> IssueRef {
|
|
IssueRef::from_str(raw).unwrap()
|
|
}
|
|
|
|
fn project() -> Project {
|
|
Project::new(
|
|
ProjectId::from_uuid(Uuid::from_u128(1)),
|
|
"demo",
|
|
ProjectPath::new("/tmp/project").unwrap(),
|
|
RemoteRef::local(),
|
|
1_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
fn profile(id: ProfileId) -> AgentProfile {
|
|
AgentProfile::new(
|
|
id,
|
|
"Claude",
|
|
"claude",
|
|
Vec::new(),
|
|
ContextInjection::stdin(),
|
|
None,
|
|
"{projectRoot}",
|
|
None,
|
|
)
|
|
.unwrap()
|
|
.with_structured_adapter(StructuredAdapter::Claude)
|
|
}
|
|
|
|
fn issue(number: u64) -> Issue {
|
|
Issue::new(
|
|
IssueId::from_uuid(Uuid::from_u128(number as u128)),
|
|
IssueNumber::new(number).unwrap(),
|
|
"Ticket title",
|
|
MarkdownDoc::new("Ticket description"),
|
|
IssueStatus::Open,
|
|
IssuePriority::High,
|
|
MarkdownDoc::new("Ticket carnet"),
|
|
Vec::new(),
|
|
Vec::new(),
|
|
IssueActor::User,
|
|
1_000,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
struct FakeIssues {
|
|
issue: Issue,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl IssueStore for FakeIssues {
|
|
async fn create(&self, _root: &ProjectPath, _issue: &Issue) -> Result<(), IssueStoreError> {
|
|
unimplemented!()
|
|
}
|
|
|
|
async fn get_by_ref(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
issue_ref: IssueRef,
|
|
) -> Result<Issue, IssueStoreError> {
|
|
if self.issue.reference() == issue_ref {
|
|
Ok(self.issue.clone())
|
|
} else {
|
|
Err(IssueStoreError::NotFound)
|
|
}
|
|
}
|
|
|
|
async fn list(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
_filter: IssueListFilter,
|
|
) -> Result<Vec<domain::IssueIndexEntry>, IssueStoreError> {
|
|
Ok(Vec::new())
|
|
}
|
|
|
|
async fn update(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
_issue: &Issue,
|
|
_expected_version: IssueVersion,
|
|
) -> Result<(), IssueStoreError> {
|
|
unimplemented!()
|
|
}
|
|
|
|
async fn delete(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
_issue_ref: IssueRef,
|
|
) -> Result<(), IssueStoreError> {
|
|
unimplemented!()
|
|
}
|
|
|
|
async fn read_carnet(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
_issue_ref: IssueRef,
|
|
) -> Result<IssueCarnet, IssueStoreError> {
|
|
unimplemented!()
|
|
}
|
|
|
|
async fn write_carnet(
|
|
&self,
|
|
_root: &ProjectPath,
|
|
_issue_ref: IssueRef,
|
|
_carnet: MarkdownDoc,
|
|
_actor: IssueActor,
|
|
_now_ms: u64,
|
|
_expected_version: IssueVersion,
|
|
) -> Result<IssueCarnet, IssueStoreError> {
|
|
unimplemented!()
|
|
}
|
|
}
|
|
|
|
struct FakeProfiles {
|
|
profile: AgentProfile,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl ProfileStore for FakeProfiles {
|
|
async fn list(&self) -> Result<Vec<AgentProfile>, StoreError> {
|
|
Ok(vec![self.profile.clone()])
|
|
}
|
|
|
|
async fn save(&self, _p: &AgentProfile) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn delete(&self, _id: ProfileId) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
|
|
async fn is_configured(&self) -> Result<bool, StoreError> {
|
|
Ok(true)
|
|
}
|
|
|
|
async fn mark_configured(&self) -> Result<(), StoreError> {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FakeAssistantContext {
|
|
prepared: Mutex<Option<PreparedContext>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AssistantContextProvider for FakeAssistantContext {
|
|
async fn prepare_ticket_assistant_context(
|
|
&self,
|
|
project: &Project,
|
|
issue: &Issue,
|
|
) -> Result<PreparedContext, AssistantContextError> {
|
|
let ctx = PreparedContext {
|
|
content: MarkdownDoc::new(format!(
|
|
"assistant\n{}\n{}\n{}",
|
|
issue.reference(),
|
|
issue.description.as_str(),
|
|
issue.carnet.as_str()
|
|
)),
|
|
relative_path: "ticket-assistant.md".to_owned(),
|
|
project_root: project.root.as_str().to_owned(),
|
|
};
|
|
*self.prepared.lock().unwrap() = Some(ctx.clone());
|
|
Ok(ctx)
|
|
}
|
|
}
|
|
|
|
struct FakeSession {
|
|
id: SessionId,
|
|
shutdowns: Arc<Mutex<usize>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentSession for FakeSession {
|
|
fn id(&self) -> SessionId {
|
|
self.id
|
|
}
|
|
|
|
fn conversation_id(&self) -> Option<String> {
|
|
None
|
|
}
|
|
|
|
async fn send(&self, _prompt: &str) -> Result<ReplyStream, AgentSessionError> {
|
|
Ok(Box::new(std::iter::empty()))
|
|
}
|
|
|
|
async fn shutdown(&self) -> Result<(), AgentSessionError> {
|
|
*self.shutdowns.lock().unwrap() += 1;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FakeFactory {
|
|
starts: Mutex<Vec<(PreparedContext, SessionPlan, Option<domain::SandboxPlan>)>>,
|
|
shutdowns: Arc<Mutex<usize>>,
|
|
}
|
|
|
|
#[async_trait]
|
|
impl AgentSessionFactory for FakeFactory {
|
|
fn supports(&self, _profile: &AgentProfile) -> bool {
|
|
true
|
|
}
|
|
|
|
async fn start(
|
|
&self,
|
|
_profile: &AgentProfile,
|
|
ctx: &PreparedContext,
|
|
_cwd: &ProjectPath,
|
|
session: &SessionPlan,
|
|
sandbox: Option<&domain::SandboxPlan>,
|
|
) -> Result<Arc<dyn AgentSession>, AgentSessionError> {
|
|
self.starts
|
|
.lock()
|
|
.unwrap()
|
|
.push((ctx.clone(), session.clone(), sandbox.cloned()));
|
|
Ok(Arc::new(FakeSession {
|
|
id: SessionId::new_random(),
|
|
shutdowns: Arc::clone(&self.shutdowns),
|
|
}))
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct FakePolicies(Mutex<HashMap<String, AgentToolPolicy>>);
|
|
|
|
impl AgentToolPolicyStore for FakePolicies {
|
|
fn set_policy(&self, requester: String, policy: AgentToolPolicy) {
|
|
self.0.lock().unwrap().insert(requester, policy);
|
|
}
|
|
|
|
fn get_policy(&self, requester: &str) -> Option<AgentToolPolicy> {
|
|
self.0.lock().unwrap().get(requester).cloned()
|
|
}
|
|
|
|
fn clear_policy(&self, requester: &str) {
|
|
self.0.lock().unwrap().remove(requester);
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct SpyBus(Mutex<Vec<DomainEvent>>);
|
|
|
|
impl EventBus for SpyBus {
|
|
fn publish(&self, event: DomainEvent) {
|
|
self.0.lock().unwrap().push(event);
|
|
}
|
|
|
|
fn subscribe(&self) -> EventStream {
|
|
Box::new(std::iter::empty())
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_then_close_ticket_assistant_sets_policy_injects_context_and_emits_events() {
|
|
let issue = issue(7);
|
|
let profile_id = ProfileId::from_uuid(Uuid::from_u128(9));
|
|
let structured = Arc::new(StructuredSessions::new());
|
|
let policies = Arc::new(FakePolicies::default());
|
|
let events = Arc::new(SpyBus::default());
|
|
let context = Arc::new(FakeAssistantContext::default());
|
|
let factory = Arc::new(FakeFactory::default());
|
|
let open = OpenTicketAssistant::new(
|
|
Arc::new(FakeIssues {
|
|
issue: issue.clone(),
|
|
}),
|
|
Arc::new(FakeProfiles {
|
|
profile: profile(profile_id),
|
|
}),
|
|
context.clone(),
|
|
factory.clone(),
|
|
structured.clone(),
|
|
policies.clone(),
|
|
events.clone(),
|
|
);
|
|
let close = CloseTicketAssistant::new(structured.clone(), policies.clone(), events.clone());
|
|
|
|
let output = open
|
|
.execute(OpenTicketAssistantInput {
|
|
project: project(),
|
|
issue_ref: issue.reference(),
|
|
profile_id,
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(structured.session(&output.session_id).is_some());
|
|
let policy = policies.get_policy(&output.requester).expect("policy set");
|
|
assert!(policy.permits("idea_ticket_read"));
|
|
assert!(policy.permits_ticket_mutation("idea_ticket_update", issue.reference()));
|
|
assert!(!policy.permits_ticket_mutation("idea_ticket_update", issue_ref("#8")));
|
|
let prepared = context.prepared.lock().unwrap().clone().unwrap();
|
|
assert!(prepared.content.as_str().contains("#7"));
|
|
assert!(prepared.content.as_str().contains("Ticket description"));
|
|
assert!(prepared.content.as_str().contains("Ticket carnet"));
|
|
let starts = factory.starts.lock().unwrap();
|
|
assert!(matches!(starts[0].1, SessionPlan::None));
|
|
assert!(starts[0].2.is_none());
|
|
drop(starts);
|
|
|
|
close
|
|
.execute(CloseTicketAssistantInput {
|
|
issue_ref: issue.reference(),
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
assert!(structured.session(&output.session_id).is_none());
|
|
assert!(policies.get_policy(&output.requester).is_none());
|
|
assert_eq!(*factory.shutdowns.lock().unwrap(), 1);
|
|
let events = events.0.lock().unwrap();
|
|
assert!(matches!(
|
|
events[0],
|
|
DomainEvent::TicketAssistantOpened { issue_ref, .. } if issue_ref == issue.reference()
|
|
));
|
|
assert!(matches!(
|
|
events[1],
|
|
DomainEvent::TicketAssistantClosed { issue_ref } if issue_ref == issue.reference()
|
|
));
|
|
}
|