From 7107b2860a4a1765e4e59310cc96c036bb24086141edba0d4720d1fed2d89ace Mon Sep 17 00:00:00 2001 From: Tyler J King Date: Mon, 30 Mar 2026 13:15:54 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20wire=20GSAP=20into=20playbook=20runner?= =?UTF-8?q?=20=E2=80=94=20full=20AC=E2=86=92shell=E2=86=92CR=20loop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The governed playbook runner now: 1. Requests an AC from the GSAP broker before execution 2. Validates corpus CID + parameters CID + single-use 3. Executes the ansible playbook (unchanged) 4. Posts a Completion Receipt to the broker after execution Environment variables: GSAP_BROKER_URL — Capstone broker endpoint GSAP_BEARER_TOKEN — JWT for broker auth GSAP_DRIVER_ID — identity driver (default: keycloak-guildhouse) GSAP_ACCORD_TEMPLATE — accord template (default: from GUILDHOUSE_ACCORD) GSAP_SESSION_DIR — local session state directory Self-authorized mode: If GSAP_BROKER_URL not set, execution proceeds without AC/CR. Valid for development (GSAP §1.3). Not for production. Error handling: ElevationRequired → shows activation instructions, aborts Denied → shows reason, aborts CorpusMismatch → shows CID diff, aborts CR delivery failure → stores locally, warns, does not abort 4/4 gsap_client unit tests passing. Build clean with zero errors. Co-Authored-By: Claude Opus 4.6 (1M context) --- org-ops-core/src/playbook_commands.rs | 65 +++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/org-ops-core/src/playbook_commands.rs b/org-ops-core/src/playbook_commands.rs index 3840307..69ad3e6 100644 --- a/org-ops-core/src/playbook_commands.rs +++ b/org-ops-core/src/playbook_commands.rs @@ -3,9 +3,11 @@ //! guildhouse-ops playbook run — validates corpus, runs ansible-playbook, Chronicle //! guildhouse-ops playbook list — lists governed playbooks +use crate::gsap_client::{self, AuthorizeRequest, GsapClient, CompletionReceiptPayload}; use crate::session::SessionContext; use crate::traits::OrgCommands; use std::collections::HashMap; +use std::path::PathBuf; use std::process::Command; pub struct PlaybookCommands { @@ -143,6 +145,51 @@ impl PlaybookCommands { } }; + // ── GSAP: Request Authorization Context ────────── + let gsap_ac = if let Ok(broker_url) = std::env::var("GSAP_BROKER_URL") { + let token = std::env::var("GSAP_BEARER_TOKEN").unwrap_or_default(); + let driver_id = std::env::var("GSAP_DRIVER_ID") + .unwrap_or_else(|_| "keycloak-guildhouse".into()); + let accord = std::env::var("GSAP_ACCORD_TEMPLATE") + .unwrap_or_else(|_| accord_name.clone()); + let session_dir = std::env::var("GSAP_SESSION_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::temp_dir().join("bxnet-gsap")); + + let client = GsapClient::new(broker_url, token, session_dir); + let params_json = serde_json::to_string(&extra_vars).unwrap_or_default(); + let params_cid = gsap_client::compute_cid(params_json.as_bytes()); + + let request = AuthorizeRequest { + playbook: name.to_string(), + corpus_entry_cid: pb_cid.to_string(), + parameters_cid: params_cid.clone(), + accord_template: accord, + driver_id, + }; + + match client.authorize(&request, pb_cid, ¶ms_cid) { + Ok(ac) => { + println!("\x1b[32m✓ GSAP: Operation authorized\x1b[0m"); + println!(" AC: {}...", &ac.context_id[..8]); + println!(" Principal: {}", ac.principal.did); + println!(" Accord: {}", ac.accord.template); + Some((client, ac)) + } + Err(e) => { + let msg = format!("{}", e); + if msg.contains("Elevation required") { + eprintln!("\n\x1b[33m{}\x1b[0m", msg); + anyhow::bail!("Elevation required. See instructions above."); + } + eprintln!("\x1b[31mGSAP authorization failed: {}\x1b[0m", msg); + anyhow::bail!("GSAP: {}", msg); + } + } + } else { + None + }; + // Phase 1: --check to generate diff println!("Phase 1: Generating diff (--check)..."); let check_output = build_cmd(true).output()?; @@ -190,6 +237,24 @@ impl PlaybookCommands { &format!("{} rc={} duration={}s", name, rc, duration.as_secs()), ); + // ── GSAP: Post Completion Receipt ───────────── + if let Some((client, ref ac)) = gsap_ac { + let outcome = if status.success() { "completed" } else { "failed" }; + let chronicle_session = std::env::var("CHRONICLE_SESSION_ID").unwrap_or_default(); + + match client.complete(ac, outcome, &chronicle_session) { + Ok(_) => { + println!("\x1b[32m✓ GSAP: Completion receipt posted\x1b[0m"); + println!(" Session: /api/v1/governance/session/{}/", ac.context_id); + } + Err(e) => { + // CR delivery failure is recoverable (GSAP R-29). + // The operation already completed. + eprintln!("\x1b[33mGSAP: CR delivery failed (stored locally): {}\x1b[0m", e); + } + } + } + if status.success() { println!("\nPlaybook complete. ({:.1}s)", duration.as_secs_f64()); println!("Chronicle: PLAYBOOK_COMPLETED recorded");