diff --git a/apps/server/src/receipts.rs b/apps/server/src/receipts.rs index 47ea654ad..4739b42d4 100644 --- a/apps/server/src/receipts.rs +++ b/apps/server/src/receipts.rs @@ -112,6 +112,8 @@ pub fn action_label(action: &str) -> &'static str { "build" => "Reconstruire l'image", "cancel_deploy" => "Arrêter un déploiement", "publish_approval" => "Accord de publication à l'agent", + "autonomous_publish" => "Publication autonome de l'agent", + "autonomous_publish_setting" => "Réglage « publication autonome »", _ => "Action", } } @@ -211,6 +213,80 @@ pub async fn record_publish_approval( } } +/// Journal d'une mise en ligne lancée par l'agent grâce à la publication autonome du projet : +/// qui a activé le réglage, quand, et le déploiement (son issue finale est réconciliée). +pub async fn record_autonomous_publish( + pool: &PgPool, + project: &Project, + setting: &devforge_agent::guard::AutonomousPublish, + deployment_uuid: &str, +) -> Option { + let enabled_by = setting + .enabled_by_label + .clone() + .unwrap_or_else(|| "un admin".into()); + let r = NewReceipt { + workspace_uuid: Some(project.workspace_uuid.clone()), + project_uuid: Some(project.uuid.clone()), + project_name: Some(project.name.clone()), + action: "autonomous_publish".into(), + action_label: action_label("autonomous_publish").into(), + target: project.name.clone(), + version: project.git_branch.clone().filter(|b| !b.trim().is_empty()), + source: "agent".into(), + approved_by: setting.enabled_by.clone(), + approved_by_label: Some(format!("Publication autonome (activée par {enabled_by})")), + approved_at: chrono::DateTime::parse_from_rfc3339(setting.enabled_at.trim()) + .ok() + .map(|d| d.with_timezone(&chrono::Utc)), + tool_name: Some("trigger_deploy".into()), + detail: Some(format!( + "Mise en ligne lancée par l'agent sans accord de 15 min (réglage activé par {enabled_by})." + )), + ..Default::default() + }; + match core::begin(pool, &r).await { + Ok(uuid) => { + let _ = core::link_deployment(pool, &uuid, deployment_uuid).await; + Some(uuid) + } + Err(e) => { + tracing::warn!(error = %e, "reçu de publication autonome non enregistré"); + None + } + } +} + +/// Changement du réglage « publication autonome » d'un projet (qui, quand, activé ou non). +pub async fn record_autonomous_setting( + pool: &PgPool, + headers: &HeaderMap, + user: &UserRow, + project: &Project, + enabled: bool, +) { + let r = NewReceipt { + workspace_uuid: Some(project.workspace_uuid.clone()), + project_uuid: Some(project.uuid.clone()), + project_name: Some(project.name.clone()), + action: "autonomous_publish_setting".into(), + action_label: action_label("autonomous_publish_setting").into(), + target: project.name.clone(), + source: source_from_headers(headers), + approved_by: Some(user.uuid.clone()), + approved_by_label: Some(user_label(user)), + detail: Some(if enabled { + "Activée : l'agent peut mettre ce projet en ligne sans accord de 15 min.".into() + } else { + "Désactivée : chaque mise en ligne de l'agent demande de nouveau un accord.".into() + }), + ..Default::default() + }; + if let Err(e) = core::record_done(pool, &r).await { + tracing::warn!(error = %e, "reçu du réglage de publication autonome non enregistré"); + } +} + #[derive(Debug, Deserialize, Default)] pub struct ListQuery { pub project: Option, @@ -356,6 +432,138 @@ mod tests { .bind(uuid).bind(status).bind(err).execute(pool).await.unwrap(); } + async fn project(pool: &PgPool, uuid: &str) -> Project { + sqlx::query_as::<_, Project>("SELECT * FROM projects WHERE uuid = $1") + .bind(uuid) + .fetch_one(pool) + .await + .unwrap() + } + + fn admin() -> UserRow { + UserRow { + uuid: "u1".into(), + email: "m@example.org".into(), + name: "Mathieu".into(), + password_hash: String::new(), + role: "instance_admin".into(), + } + } + + #[tokio::test] + async fn autonomous_publish_is_journaled_with_who_and_outcome() { + let pool = pool_with_projects().await; + let p1 = project(&pool, "p1").await; + devforge_agent::guard::set_autonomous_publish( + &pool, + "p1", + true, + Some("u1"), + Some("Mathieu"), + ) + .await + .unwrap(); + let setting = devforge_agent::guard::autonomous_publish(&pool, "p1") + .await + .unwrap(); + deployment(&pool, "d-auto", "running", None).await; + let uuid = record_autonomous_publish(&pool, &p1, &setting, "d-auto") + .await + .unwrap(); + let r = core::get(&pool, &uuid).await.unwrap().unwrap(); + assert_eq!(r.action, "autonomous_publish"); + assert_eq!(r.source, "agent"); + assert_eq!(r.status, "executing"); + assert_eq!(r.approved_by.as_deref(), Some("u1")); + assert!(r.approved_by_label.unwrap().contains("activée par Mathieu")); + assert_eq!(r.deployment_uuid.as_deref(), Some("d-auto")); + sqlx::query("UPDATE deployments SET status = 'success' WHERE uuid = 'd-auto'") + .execute(&pool) + .await + .unwrap(); + reconcile(&pool).await.unwrap(); + assert_eq!( + core::get(&pool, &uuid).await.unwrap().unwrap().status, + "done" + ); + // Visible dans le journal du workspace du projet. + let list = list_for_workspace( + &pool, + "ws1", + &ListQuery { + action: Some("autonomous_publish".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(list.len(), 1); + } + + #[tokio::test] + async fn autonomous_setting_change_leaves_a_receipt() { + let pool = pool_with_projects().await; + let p1 = project(&pool, "p1").await; + record_autonomous_setting(&pool, &HeaderMap::new(), &admin(), &p1, true).await; + record_autonomous_setting(&pool, &HeaderMap::new(), &admin(), &p1, false).await; + let list = list_for_workspace( + &pool, + "ws1", + &ListQuery { + action: Some("autonomous_publish_setting".into()), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(list.len(), 2); + assert!(list + .iter() + .all(|r| r.status == "done" && r.approved_by_label.as_deref() == Some("Mathieu"))); + assert!(list[0].detail.as_deref().unwrap().starts_with("Désactivée")); + assert!(list[1].detail.as_deref().unwrap().starts_with("Activée")); + } + + #[test] + fn only_an_admin_in_the_ui_can_change_autonomous_publish() { + use crate::routes::autonomous_edit_refusal; + assert!(autonomous_edit_refusal("instance_admin", true).is_none()); + assert!(autonomous_edit_refusal("instance_admin", false) + .unwrap() + .contains("interface")); + assert!(autonomous_edit_refusal("member", true) + .unwrap() + .contains("admin")); + assert!(autonomous_edit_refusal("member", false).is_some()); + } + + #[tokio::test] + async fn api_tokens_are_not_ui_sessions() { + let pool = pool_with_projects().await; + let later = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339(); + let earlier = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339(); + for (token, exp) in [("sess-ok", &later), ("sess-old", &earlier)] { + sqlx::query("INSERT INTO sessions (token, user_uuid, expires_at, created_at) VALUES ($1, 'u1', $2, 'x')") + .bind(token) + .bind(exp) + .execute(&pool) + .await + .unwrap(); + } + let h = |t: &str| { + let mut h = HeaderMap::new(); + h.insert( + axum::http::header::AUTHORIZATION, + format!("Bearer {t}").parse().unwrap(), + ); + h + }; + assert!(crate::routes::is_ui_session(&pool, &h("sess-ok")).await); + assert!(!crate::routes::is_ui_session(&pool, &h("sess-old")).await); + assert!(!crate::routes::is_ui_session(&pool, &h("dfat_abc")).await); + assert!(!crate::routes::is_ui_session(&pool, &HeaderMap::new()).await); + } + #[tokio::test] async fn deployment_outcome_settles_receipt() { let pool = pool_with_projects().await; diff --git a/apps/server/src/routes.rs b/apps/server/src/routes.rs index b956db8b6..68a0dad76 100644 --- a/apps/server/src/routes.rs +++ b/apps/server/src/routes.rs @@ -61,6 +61,10 @@ pub fn router() -> Router { "/api/v1/projects/{uuid}/publish-approval", post(approve_publish), ) + .route( + "/api/v1/projects/{uuid}/autonomous-publish", + get(get_autonomous_publish).put(set_autonomous_publish), + ) .route("/api/v1/databases", post(create_database)) .route("/api/v1/databases/{uuid}", get(get_database)) .route( @@ -4340,6 +4344,118 @@ async fn approve_publish( }))) } +#[derive(Deserialize)] +pub struct AutonomousPublishBody { + pub enabled: bool, +} + +/// Session ouverte dans l'interface (pas un jeton d'API, pas un jeton OAuth/MCP d'agent). +pub(crate) async fn is_ui_session(pool: &sqlx::PgPool, headers: &HeaderMap) -> bool { + let Some(token) = crate::auth_routes::bearer_from(headers) else { + return false; + }; + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query_as::<_, (i64,)>( + "SELECT 1::BIGINT FROM sessions WHERE token = $1 AND expires_at > $2 LIMIT 1", + ) + .bind(&token) + .bind(&now) + .fetch_optional(pool) + .await + .ok() + .flatten() + .is_some() +} + +/// Qui peut changer la publication autonome : un admin de l'instance, depuis l'interface. +pub(crate) fn autonomous_edit_refusal(role: &str, ui_session: bool) -> Option<&'static str> { + if role != "instance_admin" { + return Some( + "Réservé à un admin de l'instance : la publication autonome se règle par un admin.", + ); + } + if !ui_session { + return Some( + "À régler depuis l'interface DevForge : un jeton d'API ou d'agent ne peut pas changer ce réglage.", + ); + } + None +} + +fn autonomous_publish_json( + setting: Option<&devforge_agent::guard::AutonomousPublish>, + can_edit: bool, +) -> Value { + json!({ + "enabled": setting.is_some(), + "enabled_by_label": setting.and_then(|a| a.enabled_by_label.clone()), + "enabled_at": setting.map(|a| a.enabled_at.clone()), + "can_edit": can_edit, + "covers": devforge_agent::guard::AUTONOMOUS_TOOLS, + }) +} + +/// GET /api/v1/projects/{uuid}/autonomous-publish +/// Réglage « publication autonome » du projet (désactivé par défaut). +async fn get_autonomous_publish( + State(state): State, + headers: HeaderMap, + Path(uuid): Path, +) -> Result, ApiError> { + let (user, _ws, project) = auth_project(&state, &headers, &uuid).await?; + let setting = devforge_agent::guard::autonomous_publish(&state.pool, &project.uuid).await; + let can_edit = + autonomous_edit_refusal(&user.role, is_ui_session(&state.pool, &headers).await).is_none(); + Ok(Json( + json!({ "data": autonomous_publish_json(setting.as_ref(), can_edit) }), + )) +} + +/// PUT /api/v1/projects/{uuid}/autonomous-publish `{ "enabled": bool }` +/// Admin de l'instance, depuis l'interface uniquement : un jeton d'agent ne peut pas +/// s'ouvrir le verrou lui-même. Chaque changement laisse un reçu (qui, quand). +async fn set_autonomous_publish( + State(state): State, + headers: HeaderMap, + Path(uuid): Path, + Json(body): Json, +) -> Result, ApiError> { + let (user, _ws, project) = auth_project(&state, &headers, &uuid).await?; + if let Some(msg) = + autonomous_edit_refusal(&user.role, is_ui_session(&state.pool, &headers).await) + { + return Err(ApiError::forbidden(msg)); + } + let label = crate::receipts::user_label(&user); + devforge_agent::guard::set_autonomous_publish( + &state.pool, + &project.uuid, + body.enabled, + Some(&user.uuid), + Some(&label), + ) + .await + .map_err(ApiError::from)?; + crate::receipts::record_autonomous_setting( + &state.pool, + &headers, + &user, + &project, + body.enabled, + ) + .await; + let setting = devforge_agent::guard::autonomous_publish(&state.pool, &project.uuid).await; + Ok(Json(json!({ + "ok": true, + "data": autonomous_publish_json(setting.as_ref(), true), + "message": if body.enabled { + "Publication autonome activée : l'agent peut mettre ce projet en ligne sans accord de 15 min." + } else { + "Publication autonome désactivée : l'accord de 15 min est de nouveau requis." + } + }))) +} + /// POST /api/v1/projects/{uuid}/publish /// Workflow complet validé par l'utilisateur : create repo GitHub + sync workdir + optionnel deploy. async fn publish_project( diff --git a/apps/server/src/state.rs b/apps/server/src/state.rs index 4c6496376..ba4da72e2 100644 --- a/apps/server/src/state.rs +++ b/apps/server/src/state.rs @@ -385,25 +385,20 @@ impl ProjectStore for SqliteProjectStore { })); }; - // L'agent ne publie pas tout seul : accord humain (grant court) requis. - let grant: Option<(String,)> = - sqlx::query_as("SELECT expires_at FROM project_publish_grants WHERE project_uuid = $1") - .bind(project_uuid) - .fetch_optional(&self.pool) - .await - .map_err(|e| devforge_shared::DevForgeError::Message(e.to_string()))?; - let allowed = grant - .as_ref() - .map(|(exp,)| devforge_agent::guard::grant_still_valid(exp, chrono::Utc::now())) - .unwrap_or(false); - if !allowed { - return Ok(json!({ - "ok": false, - "error": "Accord explicite requis avant publication.", - "hint": "POST /api/v1/projects/{uuid}/publish-approval puis relance trigger_deploy.", - "code": "publish_approval_required" - })); - } + // L'agent ne publie pas tout seul : accord humain (grant court) requis, sauf si un + // admin a activé la publication autonome de CE projet (journalisée plus bas). + let autonomous = match devforge_agent::guard::deploy_gate(&self.pool, project_uuid).await { + devforge_agent::guard::DeployGate::Grant => None, + devforge_agent::guard::DeployGate::Autonomous(a) => Some(a), + devforge_agent::guard::DeployGate::Locked => { + return Ok(json!({ + "ok": false, + "error": "Accord explicite requis avant publication.", + "hint": "POST /api/v1/projects/{uuid}/publish-approval puis relance trigger_deploy.", + "code": "publish_approval_required" + })); + } + }; // Vérifier les pré-requis let git_repo = project.git_repository.as_deref().unwrap_or("").trim(); @@ -444,13 +439,24 @@ impl ProjectStore for SqliteProjectStore { .bind(project.id) .bind("pending") .bind(message) - .bind("[devforge] démarrage du déploiement…\n") + .bind(match &autonomous { + Some(a) => format!( + "[devforge] publication autonome (sans accord de 15 min) — réglage activé par {} le {}\n[devforge] démarrage du déploiement…\n", + a.enabled_by_label.as_deref().unwrap_or("un admin"), + a.enabled_at + ), + None => "[devforge] démarrage du déploiement…\n".to_string(), + }) .bind(&now) .bind(&now) .execute(&self.pool) .await .map_err(|e| devforge_shared::DevForgeError::Message(e.to_string()))?; + if let Some(a) = &autonomous { + crate::receipts::record_autonomous_publish(&self.pool, &project, a, &dep_uuid).await; + } + sqlx::query("UPDATE projects SET status = 'deploying', updated_at = $1 WHERE id = $2") .bind(&now) .bind(project.id) diff --git a/apps/web/src/components/AutonomousPublishPanel.test.tsx b/apps/web/src/components/AutonomousPublishPanel.test.tsx new file mode 100644 index 000000000..dd964cae8 --- /dev/null +++ b/apps/web/src/components/AutonomousPublishPanel.test.tsx @@ -0,0 +1,56 @@ +import { fireEvent, render, waitFor } from '@testing-library/preact'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AutonomousPublish } from '../lib/api'; + +const get = vi.fn<(uuid: string) => Promise<{ data: AutonomousPublish }>>(); +const put = vi.fn< + (uuid: string, enabled: boolean) => Promise<{ ok: boolean; message: string; data: AutonomousPublish }> +>(); + +vi.mock('../lib/api', () => ({ + api: { + autonomousPublish: (uuid: string) => get(uuid), + setAutonomousPublish: (uuid: string, enabled: boolean) => put(uuid, enabled), + }, +})); + +import { AutonomousPublishPanel, formatEnabledAt } from './AutonomousPublishPanel'; + +describe('AutonomousPublishPanel', () => { + beforeEach(() => { + get.mockReset(); + put.mockReset(); + }); + + it('is off by default and an admin can turn it on', async () => { + get.mockResolvedValue({ data: { enabled: false, can_edit: true } }); + put.mockResolvedValue({ + ok: true, + message: 'Publication autonome activée', + data: { enabled: true, can_edit: true, enabled_by_label: 'Mathieu', enabled_at: '2026-11-02T17:00:00Z' }, + }); + const r = render(); + const sw = await r.findByRole('switch', { name: 'Publication autonome' }); + expect(sw.getAttribute('aria-checked')).toBe('false'); + expect(r.getByText('Désactivée')).toBeTruthy(); + fireEvent.click(sw); + await waitFor(() => expect(put).toHaveBeenCalledWith('p1', true)); + await waitFor(() => expect(sw.getAttribute('aria-checked')).toBe('true')); + expect(r.getByText(/Activée par Mathieu le 02\/11\/2026 18:00/)).toBeTruthy(); + }); + + it('cannot be changed without admin rights', async () => { + get.mockResolvedValue({ data: { enabled: false, can_edit: false } }); + const r = render(); + const sw = await r.findByRole('switch', { name: 'Publication autonome' }); + expect((sw as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(sw); + expect(put).not.toHaveBeenCalled(); + expect(r.getByText(/Seul un admin/)).toBeTruthy(); + }); + + it('formats the date in Paris time', () => { + expect(formatEnabledAt('2026-07-01T10:30:00Z')).toBe('01/07/2026 12:30'); + expect(formatEnabledAt(null)).toBe(''); + }); +}); diff --git a/apps/web/src/components/AutonomousPublishPanel.tsx b/apps/web/src/components/AutonomousPublishPanel.tsx new file mode 100644 index 000000000..f995d518f --- /dev/null +++ b/apps/web/src/components/AutonomousPublishPanel.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'preact/hooks'; +import { api, type AutonomousPublish } from '../lib/api'; +import { Alert, Badge, Card, CardHeader, useToast } from './ui'; + +/** Date lisible (heure de Paris) d'un horodatage RFC 3339. */ +export function formatEnabledAt(at?: string | null): string { + if (!at) return ''; + const d = new Date(at); + if (Number.isNaN(d.getTime())) return at; + return d.toLocaleString('fr-FR', { + timeZone: 'Europe/Paris', + dateStyle: 'short', + timeStyle: 'short', + }); +} + +/** + * Réglage « publication autonome » d'un projet : un seul interrupteur, désactivé par défaut, + * modifiable par un admin de l'instance. Activé, l'agent met la branche configurée en ligne + * sans accord de 15 min ; chaque mise en ligne est inscrite dans les reçus. + */ +export function AutonomousPublishPanel({ projectUuid }: { projectUuid: string }) { + const toast = useToast(); + const [state, setState] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setError(null); + api + .autonomousPublish(projectUuid) + .then((r) => setState(r.data)) + .catch((err) => setError(String(err))); + }, [projectUuid]); + + async function toggle() { + if (!state?.can_edit) return; + setBusy(true); + try { + const r = await api.setAutonomousPublish(projectUuid, !state.enabled); + setState(r.data); + toast.push({ + title: r.data.enabled ? 'Publication autonome activée' : 'Publication autonome désactivée', + detail: r.message, + tone: 'ok', + }); + } catch (err) { + toast.push({ title: 'Réglage non modifié', detail: String(err), tone: 'danger' }); + } finally { + setBusy(false); + } + } + + const enabled = !!state?.enabled; + const who = state?.enabled_by_label?.trim(); + const when = formatEnabledAt(state?.enabled_at); + + return ( + + {enabled ? 'Activée' : 'Désactivée'}} + /> + {error && ( + + {error} + + )} + {state && ( +
+
+

+ {enabled + ? `Activée${who ? ` par ${who}` : ''}${when ? ` le ${when}` : ''}. Chaque mise en ligne de l’agent est inscrite dans les reçus (qui, quand, résultat).` + : 'Désactivée : chaque mise en ligne de l’agent attend ton accord.'} +

+ {!state.can_edit && ( +

Seul un admin de l’instance peut changer ce réglage, depuis l’interface.

+ )} +
+ +
+ )} +
+ ); +} diff --git a/apps/web/src/components/ProjectDetailPage.tsx b/apps/web/src/components/ProjectDetailPage.tsx index 9931780d0..3f1e1dab7 100644 --- a/apps/web/src/components/ProjectDetailPage.tsx +++ b/apps/web/src/components/ProjectDetailPage.tsx @@ -19,6 +19,7 @@ import { RuntimeLogsPanel } from './RuntimeLogsPanel'; import { ProjectSpecsModal } from './ProjectSpecsModal'; import { ProjectActionsPanel } from './ProjectActionsPanel'; import { ProjectGitPanel } from './ProjectGitPanel'; +import { AutonomousPublishPanel } from './AutonomousPublishPanel'; import { ProjectPreviewsPanel } from './ProjectPreviewsPanel'; import { ProjectOidcPanel } from './ProjectOidcPanel'; import { EnvBadge, ProjectBranchesPanel, isDevDeployment } from './ProjectBranchesPanel'; @@ -429,13 +430,16 @@ export function ProjectDetailPage(props: Props) { /> )} {tab === 'git' && ( - { - void api.deployments(uuid).then((r) => setDeployments(r.data ?? [])); - }} - /> + <> + { + void api.deployments(uuid).then((r) => setDeployments(r.data ?? [])); + }} + /> + + )} {tab === 'actions' && ( + request<{ data: AutonomousPublish }>(`/projects/${uuid}/autonomous-publish`), + setAutonomousPublish: (uuid: string, enabled: boolean) => + request<{ ok: boolean; message: string; data: AutonomousPublish }>( + `/projects/${uuid}/autonomous-publish`, + { method: 'PUT', body: JSON.stringify({ enabled }) }, + ), projectOidc: (uuid: string) => request<{ ok: boolean; diff --git a/crates/agent/src/guard.rs b/crates/agent/src/guard.rs index e51fb9e03..0a859a757 100644 --- a/crates/agent/src/guard.rs +++ b/crates/agent/src/guard.rs @@ -55,10 +55,24 @@ pub fn is_remote_write(tool: &str, args: &Value) -> bool { false } +/// Outils que la « publication autonome » d'un projet débloque sans accord de 15 min. +/// Uniquement la mise en ligne de la branche configurée : pousser sur GitHub, créer un dépôt, +/// toucher aux variables de prod ou annuler un déploiement restent verrouillés. +pub const AUTONOMOUS_TOOLS: &[&str] = &["trigger_deploy"]; + +/// Le réglage « publication autonome » couvre-t-il cet outil ? +pub fn autonomous_covers(tool: &str) -> bool { + AUTONOMOUS_TOOLS.contains(&tool) +} + /// Accord de publication en cours de validité pour un projet. #[async_trait] pub trait PublishGate: Send + Sync { async fn allowed(&self, project_uuid: &str) -> bool; + /// Publication autonome activée pour ce projet (réglage admin, désactivé par défaut). + async fn autonomous(&self, _project_uuid: &str) -> bool { + false + } } /// Gate réel : lit `project_publish_grants.expires_at` (RFC 3339, comparé à maintenant). @@ -71,6 +85,94 @@ impl PublishGate for PgPublishGate { async fn allowed(&self, project_uuid: &str) -> bool { publish_grant_valid(&self.pool, project_uuid).await } + async fn autonomous(&self, project_uuid: &str) -> bool { + autonomous_publish(&self.pool, project_uuid).await.is_some() + } +} + +/// Qui a activé la publication autonome d'un projet, et quand. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AutonomousPublish { + pub enabled_by: Option, + pub enabled_by_label: Option, + pub enabled_at: String, +} + +/// Réglage « publication autonome » du projet (`None` = désactivé, la valeur par défaut). +/// Une erreur de lecture vaut « désactivé » : le verrou reste fermé. +pub async fn autonomous_publish(pool: &PgPool, project_uuid: &str) -> Option { + if project_uuid.trim().is_empty() { + return None; + } + let row: Option<(Option, Option, String)> = sqlx::query_as( + "SELECT enabled_by, enabled_by_label, enabled_at FROM project_autonomous_publish WHERE project_uuid = $1", + ) + .bind(project_uuid) + .fetch_optional(pool) + .await + .ok() + .flatten(); + row.map( + |(enabled_by, enabled_by_label, enabled_at)| AutonomousPublish { + enabled_by, + enabled_by_label, + enabled_at, + }, + ) +} + +/// Ce qui autorise (ou non) une mise en ligne lancée par un agent. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DeployGate { + /// Accord humain de 15 min en cours. + Grant, + /// Publication autonome activée pour ce projet (journalisée à chaque mise en ligne). + Autonomous(AutonomousPublish), + /// Verrou fermé : `publish_approval_required`. + Locked, +} + +/// Décision du verrou de publication pour `trigger_deploy` (accord d'abord, puis réglage). +pub async fn deploy_gate(pool: &PgPool, project_uuid: &str) -> DeployGate { + if publish_grant_valid(pool, project_uuid).await { + return DeployGate::Grant; + } + match autonomous_publish(pool, project_uuid).await { + Some(a) => DeployGate::Autonomous(a), + None => DeployGate::Locked, + } +} + +/// Active (ou désactive) la publication autonome d'un projet. L'appelant vérifie le rôle admin. +pub async fn set_autonomous_publish( + pool: &PgPool, + project_uuid: &str, + enabled: bool, + by: Option<&str>, + by_label: Option<&str>, +) -> Result<(), sqlx::Error> { + if enabled { + sqlx::query( + r#"INSERT INTO project_autonomous_publish (project_uuid, enabled_by, enabled_by_label, enabled_at) + VALUES ($1, $2, $3, $4) + ON CONFLICT(project_uuid) DO UPDATE SET + enabled_by = excluded.enabled_by, + enabled_by_label = excluded.enabled_by_label, + enabled_at = excluded.enabled_at"#, + ) + .bind(project_uuid) + .bind(by) + .bind(by_label) + .bind(chrono::Utc::now().to_rfc3339()) + .execute(pool) + .await?; + } else { + sqlx::query("DELETE FROM project_autonomous_publish WHERE project_uuid = $1") + .bind(project_uuid) + .execute(pool) + .await?; + } + Ok(()) } /// Gate fixe (tests, ou registre sans base : refuse par défaut). @@ -288,6 +390,102 @@ pub fn publish_intent(msg: &str) -> PublishIntent { } } +#[cfg(test)] +mod autonomous_tests { + use super::*; + + async fn pg() -> PgPool { + let pool = devforge_database::ephemeral_pg().await; + sqlx::query( + "CREATE TABLE projects (uuid TEXT PRIMARY KEY, name TEXT, workspace_uuid TEXT)", + ) + .execute(&pool) + .await + .unwrap(); + crate::receipts::migrate(&pool).await.unwrap(); + pool + } + + #[test] + fn autonomous_covers_only_the_deploy() { + assert!(autonomous_covers("trigger_deploy")); + for t in ALWAYS_REMOTE.iter().filter(|t| **t != "trigger_deploy") { + assert!(!autonomous_covers(t), "{t}"); + } + assert!(!autonomous_covers("mcp_call_tool")); + assert!(!autonomous_covers("write_project_file")); + } + + #[tokio::test] + async fn deploy_gate_prefers_the_grant_then_the_setting() { + let pool = pg().await; + assert_eq!(deploy_gate(&pool, "p1").await, DeployGate::Locked); + set_autonomous_publish(&pool, "p1", true, Some("u1"), Some("Mathieu")) + .await + .unwrap(); + match deploy_gate(&pool, "p1").await { + DeployGate::Autonomous(a) => assert_eq!(a.enabled_by_label.as_deref(), Some("Mathieu")), + other => panic!("attendu Autonomous, reçu {other:?}"), + } + assert_eq!(deploy_gate(&pool, "p2").await, DeployGate::Locked); + sqlx::query("INSERT INTO project_publish_grants (project_uuid, granted_at, expires_at) VALUES ('p1', $1, $2)") + .bind(chrono::Utc::now().to_rfc3339()) + .bind((chrono::Utc::now() + chrono::Duration::minutes(15)).to_rfc3339()) + .execute(&pool) + .await + .unwrap(); + assert_eq!(deploy_gate(&pool, "p1").await, DeployGate::Grant); + // Un accord expiré ne compte plus : on retombe sur le réglage. + sqlx::query("UPDATE project_publish_grants SET expires_at = $1 WHERE project_uuid = 'p1'") + .bind((chrono::Utc::now() - chrono::Duration::minutes(1)).to_rfc3339()) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + deploy_gate(&pool, "p1").await, + DeployGate::Autonomous(_) + )); + } + + #[tokio::test] + async fn static_gate_is_never_autonomous() { + assert!(!StaticGate(true).autonomous("p1").await); + } + + #[tokio::test] + async fn autonomous_publish_is_off_by_default_and_per_project() { + let pool = pg().await; + let gate = PgPublishGate { + pool: Arc::new(pool.clone()), + }; + assert!(autonomous_publish(&pool, "p1").await.is_none()); + assert!(!gate.autonomous("p1").await); + assert!(autonomous_publish(&pool, "").await.is_none()); + + set_autonomous_publish(&pool, "p1", true, Some("u1"), Some("Mathieu")) + .await + .unwrap(); + let a = autonomous_publish(&pool, "p1").await.unwrap(); + assert_eq!(a.enabled_by.as_deref(), Some("u1")); + assert_eq!(a.enabled_by_label.as_deref(), Some("Mathieu")); + assert!(chrono::DateTime::parse_from_rfc3339(&a.enabled_at).is_ok()); + assert!(gate.autonomous("p1").await); + // Un autre projet reste verrouillé, et l'accord de 15 min n'est pas posé pour autant. + assert!(!gate.autonomous("p2").await); + assert!(!gate.allowed("p1").await); + + set_autonomous_publish(&pool, "p1", false, Some("u1"), Some("Mathieu")) + .await + .unwrap(); + assert!(!gate.autonomous("p1").await); + assert_eq!(deploy_gate(&pool, "p1").await, DeployGate::Locked); + // Désactiver deux fois ne casse rien. + set_autonomous_publish(&pool, "p1", false, None, None) + .await + .unwrap(); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/agent/src/lib.rs b/crates/agent/src/lib.rs index 57e1b83a8..8308f0f83 100644 --- a/crates/agent/src/lib.rs +++ b/crates/agent/src/lib.rs @@ -103,6 +103,17 @@ impl ToolRegistry { } } + /// Publication autonome du projet couvrant cet outil (réglage admin, désactivé par défaut). + pub async fn autonomous_allowed(&self, project_uuid: &str, tool: &str) -> bool { + if project_uuid.trim().is_empty() || !guard::autonomous_covers(tool) { + return false; + } + match &self.gate { + Some(gate) => gate.autonomous(project_uuid).await, + None => false, + } + } + /// Schéma JSON des paramètres d'un outil (coercition des arguments). pub fn schema(&self, name: &str) -> Option { self.tools.get(name).map(|t| t.parameters()) @@ -1085,6 +1096,12 @@ impl AgentRunner { return None; } let project_uuid = remote_project(args, ctx); + // Publication autonome (réglage par projet, posé par un admin) : la mise en ligne + // passe sans accord de 15 min, y compris en exécution automatique. Le serveur + // revérifie le réglage et journalise la publication (qui, quand, quel projet). + if self.registry.autonomous_allowed(&project_uuid, tool).await { + return None; + } let blocked = if ctx.unattended { guard::remote_write_blocked_unattended(tool, args) } else if self.registry.publish_allowed(&project_uuid).await { @@ -1177,6 +1194,13 @@ impl AgentRunner { Ok(None) => {} Err(e) => tracing::warn!(error = %e, "reçu : validation non consommée"), } + if !self.registry.publish_allowed(&project_uuid).await + && self.registry.autonomous_allowed(&project_uuid, tool).await + { + // Pas d'accord humain en cours : c'est la publication autonome qui autorise. + // Le reçu est écrit par `trigger_deploy` côté serveur (aussi pour les appels MCP). + return None; + } if let Some((by, label, at)) = receipts::grant_approver(&pool, &project_uuid).await { r.approved_by = by; r.approved_by_label = label; diff --git a/crates/agent/src/loop_tests.rs b/crates/agent/src/loop_tests.rs index 175e684ae..adcd13caf 100644 --- a/crates/agent/src/loop_tests.rs +++ b/crates/agent/src/loop_tests.rs @@ -1106,3 +1106,85 @@ async fn images_go_multimodal_only_for_vision_models() { last.content ); } + +/// Gate de test : aucun accord de 15 min, publication autonome activée ou non. +struct AutonomousOnly(bool); + +#[async_trait] +impl guard::PublishGate for AutonomousOnly { + async fn allowed(&self, _project_uuid: &str) -> bool { + false + } + async fn autonomous(&self, _project_uuid: &str) -> bool { + self.0 + } +} + +#[tokio::test] +async fn autonomous_publish_lets_only_the_deploy_through() { + let mut reg = ToolRegistry::new(); + reg.set_publish_gate(Arc::new(AutonomousOnly(true))); + let deploy = spy( + &mut reg, + "trigger_deploy", + json!({"ok": true, "deployment_uuid": "d1"}), + ); + let sync = spy(&mut reg, "sync_workdir_to_github", json!({"ok": true})); + let env = spy(&mut reg, "upsert_env_var", json!({"ok": true})); + let cancel = spy(&mut reg, "cancel_deployment", json!({"ok": true})); + let turns = vec![ + ScriptedLlmProvider::tool("c1", "trigger_deploy", json!({})), + ScriptedLlmProvider::tool("c2", "sync_workdir_to_github", json!({})), + ScriptedLlmProvider::tool("c3", "upsert_env_var", json!({"key": "A", "value": "b"})), + ScriptedLlmProvider::tool("c4", "cancel_deployment", json!({"deployment_uuid": "d1"})), + ScriptedLlmProvider::text("Mise en ligne lancée."), + ]; + let (reply, _) = run(reg, turns, "publie la dernière version", ctx("coordinator")).await; + assert_eq!(deploy.load(Ordering::SeqCst), 1); + assert_eq!(sync.load(Ordering::SeqCst), 0); + assert_eq!(env.load(Ordering::SeqCst), 0); + assert_eq!(cancel.load(Ordering::SeqCst), 0); + for r in reply + .tool_calls + .iter() + .filter(|r| r.name != "trigger_deploy") + { + assert_eq!(r.result["code"], "publish_approval_required", "{}", r.name); + } +} + +#[tokio::test] +async fn autonomous_publish_also_covers_unattended_runs() { + let mut reg = ToolRegistry::new(); + reg.set_publish_gate(Arc::new(AutonomousOnly(true))); + let deploy = spy( + &mut reg, + "trigger_deploy", + json!({"ok": true, "deployment_uuid": "d1"}), + ); + let turns = vec![ + ScriptedLlmProvider::tool("c1", "trigger_deploy", json!({})), + ScriptedLlmProvider::text("Mise en ligne lancée."), + ]; + let mut c = ctx("coordinator"); + c.unattended = true; + run(reg, turns, "réveil : nouvelle version", c).await; + assert_eq!(deploy.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn autonomous_publish_off_keeps_the_lock() { + let mut reg = ToolRegistry::new(); + reg.set_publish_gate(Arc::new(AutonomousOnly(false))); + let deploy = spy(&mut reg, "trigger_deploy", json!({"ok": true})); + let turns = vec![ + ScriptedLlmProvider::tool("c1", "trigger_deploy", json!({})), + ScriptedLlmProvider::text("Bloqué."), + ]; + let (reply, _) = run(reg, turns, "publie", ctx("coordinator")).await; + assert_eq!(deploy.load(Ordering::SeqCst), 0); + assert_eq!( + reply.tool_calls[0].result["code"], + "publish_approval_required" + ); +} diff --git a/crates/agent/src/receipts.rs b/crates/agent/src/receipts.rs index 634b3c550..3df557e25 100644 --- a/crates/agent/src/receipts.rs +++ b/crates/agent/src/receipts.rs @@ -67,6 +67,9 @@ pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> { "CREATE TABLE IF NOT EXISTS project_publish_grants (project_uuid TEXT PRIMARY KEY, granted_at TEXT NOT NULL, expires_at TEXT NOT NULL)", "ALTER TABLE project_publish_grants ADD COLUMN IF NOT EXISTS granted_by TEXT NULL", "ALTER TABLE project_publish_grants ADD COLUMN IF NOT EXISTS granted_by_label TEXT NULL", + // Publication autonome : réglage par projet (absent = désactivé). Seul un admin + // connecté à l'interface le pose ; il ne débloque que `trigger_deploy`. + "CREATE TABLE IF NOT EXISTS project_autonomous_publish (project_uuid TEXT PRIMARY KEY, enabled_by TEXT NULL, enabled_by_label TEXT NULL, enabled_at TEXT NOT NULL)", ] { sqlx::query(sql).execute(pool).await?; }