From e5a66320a85f9e138531234813ff886ee4ea19dd Mon Sep 17 00:00:00 2001 From: Mathieu JESER <6737167+bobdivx@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:44:36 +0100 Subject: [PATCH] =?UTF-8?q?feat(publish):=20r=C3=A9glage=20=C2=AB=20public?= =?UTF-8?q?ation=20autonome=20=C2=BB=20par=20projet=20(admin,=20d=C3=A9sac?= =?UTF-8?q?tiv=C3=A9=20par=20d=C3=A9faut)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un admin de l'instance peut, depuis l'interface, autoriser l'agent à mettre un projet en ligne (trigger_deploy) sans l'accord de 15 min. Désactivé par défaut, réglé projet par projet, jamais global. Pousser sur GitHub, créer un dépôt, modifier les variables de prod ou annuler un déploiement restent verrouillés. Chaque mise en ligne autonome et chaque changement du réglage laissent un reçu (qui, quand, résultat). --- apps/server/src/receipts.rs | 208 ++++++++++++++++++ apps/server/src/routes.rs | 116 ++++++++++ apps/server/src/state.rs | 46 ++-- .../AutonomousPublishPanel.test.tsx | 56 +++++ .../src/components/AutonomousPublishPanel.tsx | 104 +++++++++ apps/web/src/components/ProjectDetailPage.tsx | 18 +- apps/web/src/lib/api.ts | 18 ++ crates/agent/src/guard.rs | 198 +++++++++++++++++ crates/agent/src/lib.rs | 24 ++ crates/agent/src/loop_tests.rs | 82 +++++++ crates/agent/src/receipts.rs | 3 + 11 files changed, 846 insertions(+), 27 deletions(-) create mode 100644 apps/web/src/components/AutonomousPublishPanel.test.tsx create mode 100644 apps/web/src/components/AutonomousPublishPanel.tsx 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?; }