feat: session mode — one AC for N commands
Per-invocation AC is the primitive for single governed ops. Session mode is for scripts, pipelines, and interactive shells. Per-invocation (unchanged): gsh --exec "cmd" → 1 AC + 1 CR per command Session mode (new): eval "$(gsh session-start --scope shell:session)" gsh --exec "cmd1" # reuses session AC gsh --exec "cmd2" eval "$(gsh session-end)" Detection: GSAP_SESSION_AC in environment. Subcommands: session-start, session-end, session-status Known gap: broker currently marks AC consumed after first CR. Session commands 2+ get 404 on CR. This is a broker-side fix (needs session AC type). gsh handles it gracefully. Tested against live fastapi-gsap Spoke on Hetzner. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eab034f0cc
commit
2f9401d3c4
1 changed files with 204 additions and 190 deletions
394
src/main.rs
394
src/main.rs
|
|
@ -1,19 +1,30 @@
|
||||||
//! gsh — Governed Shell (machine mode)
|
//! gsh — Governed Shell
|
||||||
//!
|
//!
|
||||||
//! GCAP-SPEC-SHELLBOUND-SDK-0001
|
//! GCAP-SPEC-SHELLBOUND-SDK-0001
|
||||||
//!
|
//!
|
||||||
//! Usage:
|
//! Two execution models:
|
||||||
//! gsh --exec "echo hello"
|
//!
|
||||||
//! gsh --exec "hcloud server list" --json
|
//! 1. Per-invocation (machine mode):
|
||||||
|
//! gsh --exec "command"
|
||||||
|
//! One AC per command. For single governed ops.
|
||||||
|
//!
|
||||||
|
//! 2. Session mode:
|
||||||
|
//! eval "$(gsh session-start)"
|
||||||
|
//! gsh --exec "cmd1" # reuses session AC
|
||||||
|
//! gsh --exec "cmd2"
|
||||||
|
//! gsh session-end
|
||||||
|
//! One AC for the session. N CRs.
|
||||||
//!
|
//!
|
||||||
//! Environment:
|
//! Environment:
|
||||||
//! GSAP_BROKER_URL http://fastapi-gsap:8000
|
//! GSAP_BROKER_URL required
|
||||||
//! GSAP_AGENT_DID did:web:bxnet.../agent/platform-ops
|
//! GSAP_AGENT_DID required
|
||||||
//! GSAP_TOKEN Bearer token for broker auth (optional)
|
//! GSAP_TOKEN optional Bearer auth
|
||||||
//! GSAP_CORPUS_CID Corpus entry CID (optional)
|
//! GSAP_CORPUS_CID optional
|
||||||
|
//! GSAP_SESSION_AC set by session-start
|
||||||
|
//! GSAP_SESSION_ID set by session-start
|
||||||
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use clap::Parser;
|
use clap::{Parser, Subcommand};
|
||||||
use reqwest::blocking::Client;
|
use reqwest::blocking::Client;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
|
@ -21,35 +32,48 @@ use std::process;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
// ── CLI ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(name = "gsh", about = "Governed shell — GCAP machine mode")]
|
#[command(name = "gsh", about = "Governed shell — GCAP machine mode")]
|
||||||
struct Args {
|
struct Args {
|
||||||
/// Command to execute (via sh -c)
|
#[command(subcommand)]
|
||||||
#[arg(long, short = 'e')]
|
command: Option<Cmd>,
|
||||||
exec: String,
|
|
||||||
|
|
||||||
/// Override broker URL (default: $GSAP_BROKER_URL)
|
/// Command to execute (via sh -c)
|
||||||
#[arg(long)]
|
#[arg(long, short = 'e', global = true)]
|
||||||
|
exec: Option<String>,
|
||||||
|
|
||||||
|
#[arg(long, global = true)]
|
||||||
broker_url: Option<String>,
|
broker_url: Option<String>,
|
||||||
|
|
||||||
/// Override agent DID (default: $GSAP_AGENT_DID)
|
#[arg(long, global = true)]
|
||||||
#[arg(long)]
|
|
||||||
agent_did: Option<String>,
|
agent_did: Option<String>,
|
||||||
|
|
||||||
/// Operation label (default: shell:exec)
|
#[arg(long, default_value = "shell:exec", global = true)]
|
||||||
#[arg(long, default_value = "shell:exec")]
|
|
||||||
operation: String,
|
operation: String,
|
||||||
|
|
||||||
/// JSON output with CIDs
|
#[arg(long, global = true)]
|
||||||
#[arg(long)]
|
|
||||||
json: bool,
|
json: bool,
|
||||||
|
|
||||||
/// Request AC but skip execution
|
#[arg(long, global = true)]
|
||||||
#[arg(long)]
|
|
||||||
dry_run: bool,
|
dry_run: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GSAP broker request/response types ──────────────────────
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Start a governed session (one AC for many commands)
|
||||||
|
SessionStart {
|
||||||
|
#[arg(long, default_value = "shell:session")]
|
||||||
|
scope: String,
|
||||||
|
},
|
||||||
|
/// End a governed session
|
||||||
|
SessionEnd,
|
||||||
|
/// Show session status
|
||||||
|
SessionStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GSAP types (match broker's expected format) ───────────────
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct AcRequest {
|
struct AcRequest {
|
||||||
|
|
@ -62,12 +86,13 @@ struct AcRequest {
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AcResponse {
|
struct AcResponse {
|
||||||
|
#[allow(dead_code)]
|
||||||
status: Option<String>,
|
status: Option<String>,
|
||||||
authorization_context: Option<AcContext>,
|
authorization_context: Option<AcCtx>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct AcContext {
|
struct AcCtx {
|
||||||
context_id: String,
|
context_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -105,206 +130,195 @@ struct GshOutput {
|
||||||
stdout: String,
|
stdout: String,
|
||||||
stderr: String,
|
stderr: String,
|
||||||
ac_id: String,
|
ac_id: String,
|
||||||
|
session_ac: bool,
|
||||||
cr_id: String,
|
cr_id: String,
|
||||||
chronicle_cid: String,
|
chronicle_cid: String,
|
||||||
command_hash: String,
|
command_hash: String,
|
||||||
run_id: String,
|
run_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Main ────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn sha256_hash(data: &[u8]) -> String {
|
||||||
|
format!("sha256:{}", hex::encode(Sha256::digest(data)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_client(token: &Option<String>) -> Result<Client> {
|
||||||
|
let mut headers = reqwest::header::HeaderMap::new();
|
||||||
|
if let Some(tok) = token {
|
||||||
|
headers.insert("Authorization", format!("Bearer {}", tok).parse().context("Invalid token")?);
|
||||||
|
}
|
||||||
|
Client::builder()
|
||||||
|
.timeout(Duration::from_secs(30))
|
||||||
|
.default_headers(headers)
|
||||||
|
.build()
|
||||||
|
.context("HTTP client failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn broker_url(base: &str, path: &str) -> String {
|
||||||
|
format!("{}/{}", base.trim_end_matches('/'), path.trim_start_matches('/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_ac(client: &Client, base: &str, operation: &str, command_hash: &str, corpus_cid: &str) -> Result<String> {
|
||||||
|
let resp = client
|
||||||
|
.post(broker_url(base, "governance/authorize/"))
|
||||||
|
.json(&AcRequest {
|
||||||
|
driver_id: "keycloak".into(),
|
||||||
|
playbook: format!("{}:{}", operation, &command_hash[..20.min(command_hash.len())]),
|
||||||
|
corpus_entry_cid: corpus_cid.into(),
|
||||||
|
parameters_cid: command_hash.into(),
|
||||||
|
accord_template: "shell-exec".into(),
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
.context("Failed to reach broker")?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
bail!("AC denied: {} — {}", resp.status(), resp.text().unwrap_or_default());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ac: AcResponse = resp.json().context("Invalid AC response")?;
|
||||||
|
ac.authorization_context
|
||||||
|
.map(|c| c.context_id)
|
||||||
|
.filter(|id| !id.is_empty())
|
||||||
|
.context("No AC ID returned")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post_cr(client: &Client, base: &str, ac_id: &str, outcome: &str) -> (String, String) {
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
match client
|
||||||
|
.post(broker_url(base, "governance/complete/"))
|
||||||
|
.json(&CrRequest {
|
||||||
|
context_id: ac_id.into(),
|
||||||
|
outcome: outcome.into(),
|
||||||
|
completed_at: now,
|
||||||
|
chronicle_evidence: CrEvidence { events: vec![], merkle_root: String::new() },
|
||||||
|
behavioral_attestation: CrAttestation { status: "unavailable".into() },
|
||||||
|
ffc: serde_json::json!({"did": "did:web:guildhouse.dev"}),
|
||||||
|
signature: serde_json::json!({"value": "gsh"}),
|
||||||
|
})
|
||||||
|
.send()
|
||||||
|
{
|
||||||
|
Ok(r) if r.status().is_success() => {
|
||||||
|
let cr: CrResponse = r.json().unwrap_or(CrResponse { receipt_id: None, chronicle_event_cid: None });
|
||||||
|
(cr.receipt_id.unwrap_or_default(), cr.chronicle_event_cid.unwrap_or_default())
|
||||||
|
}
|
||||||
|
Ok(r) => { eprintln!("gsh: CR failed: {}", r.status()); (String::new(), String::new()) }
|
||||||
|
Err(e) => { eprintln!("gsh: CR error: {}", e); (String::new(), String::new()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
match run(args) {
|
match run(args) {
|
||||||
Ok(code) => process::exit(code),
|
Ok(code) => process::exit(code),
|
||||||
Err(e) => {
|
Err(e) => { eprintln!("gsh: {:#}", e); process::exit(125); }
|
||||||
eprintln!("gsh: {:#}", e);
|
|
||||||
process::exit(125);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn run(args: Args) -> Result<i32> {
|
fn run(args: Args) -> Result<i32> {
|
||||||
let broker_url = args
|
let base = args.broker_url.clone()
|
||||||
.broker_url
|
|
||||||
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
|
.or_else(|| std::env::var("GSAP_BROKER_URL").ok())
|
||||||
.context("GSAP_BROKER_URL not set")?;
|
.context("GSAP_BROKER_URL not set")?;
|
||||||
|
let corpus = std::env::var("GSAP_CORPUS_CID").unwrap_or_else(|_| "sha256:ungoverned".into());
|
||||||
let agent_did = args
|
|
||||||
.agent_did
|
|
||||||
.or_else(|| std::env::var("GSAP_AGENT_DID").ok())
|
|
||||||
.context("GSAP_AGENT_DID not set")?;
|
|
||||||
|
|
||||||
let corpus_cid =
|
|
||||||
std::env::var("GSAP_CORPUS_CID").unwrap_or_else(|_| "sha256:ungoverned".into());
|
|
||||||
|
|
||||||
let token = std::env::var("GSAP_TOKEN").ok();
|
let token = std::env::var("GSAP_TOKEN").ok();
|
||||||
|
|
||||||
|
// Route subcommands:
|
||||||
|
if let Some(cmd) = &args.command {
|
||||||
|
let client = build_client(&token)?;
|
||||||
|
return match cmd {
|
||||||
|
Cmd::SessionStart { scope } => {
|
||||||
|
let hash = sha256_hash(format!("session:{}", scope).as_bytes());
|
||||||
|
eprintln!("gsh: starting session (scope: {})", scope);
|
||||||
|
let ac_id = request_ac(&client, &base, scope, &hash, &corpus)?;
|
||||||
|
let session_id = Uuid::new_v4().to_string();
|
||||||
|
eprintln!("gsh: session AC — {}", &ac_id[..8.min(ac_id.len())]);
|
||||||
|
println!("export GSAP_SESSION_AC=\"{}\";", ac_id);
|
||||||
|
println!("export GSAP_SESSION_ID=\"{}\";", session_id);
|
||||||
|
println!("export GSAP_SESSION_SCOPE=\"{}\";", scope);
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
Cmd::SessionEnd => {
|
||||||
|
let ac_id = std::env::var("GSAP_SESSION_AC")
|
||||||
|
.context("No session active (GSAP_SESSION_AC not set)")?;
|
||||||
|
let (_, cid) = post_cr(&client, &base, &ac_id, "completed");
|
||||||
|
eprintln!("gsh: session closed");
|
||||||
|
if !cid.is_empty() {
|
||||||
|
eprintln!("gsh: session CID {}", &cid[..40.min(cid.len())]);
|
||||||
|
}
|
||||||
|
println!("unset GSAP_SESSION_AC GSAP_SESSION_ID GSAP_SESSION_SCOPE;");
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
Cmd::SessionStatus => {
|
||||||
|
match std::env::var("GSAP_SESSION_AC") {
|
||||||
|
Ok(ac) => {
|
||||||
|
println!("Session active: {}", &ac[..8.min(ac.len())]);
|
||||||
|
if let Ok(sid) = std::env::var("GSAP_SESSION_ID") { println!("Session ID: {}", sid); }
|
||||||
|
if let Ok(scope) = std::env::var("GSAP_SESSION_SCOPE") { println!("Scope: {}", scope); }
|
||||||
|
}
|
||||||
|
Err(_) => println!("No session active.\nStart: eval \"$(gsh session-start)\""),
|
||||||
|
}
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --exec mode:
|
||||||
|
let exec = args.exec.as_ref().context("Provide --exec 'command' or a subcommand. Try: gsh --help")?;
|
||||||
|
let client = build_client(&token)?;
|
||||||
let run_id = Uuid::new_v4().to_string();
|
let run_id = Uuid::new_v4().to_string();
|
||||||
|
let command_hash = sha256_hash(exec.as_bytes());
|
||||||
|
|
||||||
// Hash the command — the broker sees WHAT will be executed
|
// Session mode or per-invocation:
|
||||||
let command_hash = format!(
|
let (ac_id, using_session) = match std::env::var("GSAP_SESSION_AC") {
|
||||||
"sha256:{}",
|
Ok(session_ac) => (session_ac, true),
|
||||||
hex::encode(Sha256::digest(args.exec.as_bytes()))
|
Err(_) => {
|
||||||
);
|
eprintln!("gsh: requesting AC for '{}'", exec);
|
||||||
|
let id = request_ac(&client, &base, &args.operation, &command_hash, &corpus)?;
|
||||||
// Build client
|
eprintln!("gsh: AC issued — {}", &id[..8.min(id.len())]);
|
||||||
let mut headers = reqwest::header::HeaderMap::new();
|
(id, false)
|
||||||
if let Some(ref tok) = token {
|
}
|
||||||
headers.insert(
|
};
|
||||||
"Authorization",
|
|
||||||
format!("Bearer {}", tok)
|
|
||||||
.parse()
|
|
||||||
.context("Invalid GSAP_TOKEN")?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let client = Client::builder()
|
|
||||||
.timeout(Duration::from_secs(30))
|
|
||||||
.default_headers(headers)
|
|
||||||
.build()
|
|
||||||
.context("HTTP client build failed")?;
|
|
||||||
|
|
||||||
// ── Request AC ──────────────────────────────────────────
|
|
||||||
|
|
||||||
eprintln!("gsh: requesting AC for '{}'", args.exec);
|
|
||||||
|
|
||||||
let ac_resp = client
|
|
||||||
.post(format!(
|
|
||||||
"{}/governance/authorize/",
|
|
||||||
broker_url.trim_end_matches('/')
|
|
||||||
))
|
|
||||||
.json(&AcRequest {
|
|
||||||
driver_id: "keycloak".into(),
|
|
||||||
playbook: format!("{}:{}", args.operation, &command_hash[..20]),
|
|
||||||
corpus_entry_cid: corpus_cid,
|
|
||||||
parameters_cid: command_hash.clone(),
|
|
||||||
accord_template: "shell-exec".into(),
|
|
||||||
})
|
|
||||||
.send()
|
|
||||||
.context("Failed to reach GSAP broker")?;
|
|
||||||
|
|
||||||
if !ac_resp.status().is_success() {
|
|
||||||
let status = ac_resp.status();
|
|
||||||
let body = ac_resp.text().unwrap_or_default();
|
|
||||||
bail!("AC denied: {} — {}", status, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
let ac: AcResponse = ac_resp.json().context("Invalid AC response")?;
|
|
||||||
|
|
||||||
let ac_id = ac
|
|
||||||
.authorization_context
|
|
||||||
.as_ref()
|
|
||||||
.map(|c| c.context_id.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if ac_id.is_empty() {
|
|
||||||
bail!(
|
|
||||||
"AC not authorized: status={:?}",
|
|
||||||
ac.status
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("gsh: AC issued — {}", &ac_id[..8.min(ac_id.len())]);
|
|
||||||
|
|
||||||
if args.dry_run {
|
if args.dry_run {
|
||||||
eprintln!("gsh: dry-run — skipping execution");
|
eprintln!("gsh: dry-run — skipping exec");
|
||||||
if args.json {
|
if args.json {
|
||||||
println!(
|
println!("{}", serde_json::to_string_pretty(&GshOutput {
|
||||||
"{}",
|
exit_code: 0, stdout: String::new(), stderr: String::new(),
|
||||||
serde_json::to_string_pretty(&GshOutput {
|
ac_id, session_ac: using_session, cr_id: String::new(),
|
||||||
exit_code: 0,
|
chronicle_cid: String::new(), command_hash, run_id,
|
||||||
stdout: String::new(),
|
})?);
|
||||||
stderr: String::new(),
|
|
||||||
ac_id,
|
|
||||||
cr_id: String::new(),
|
|
||||||
chronicle_cid: String::new(),
|
|
||||||
command_hash,
|
|
||||||
run_id,
|
|
||||||
})?
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Execute ─────────────────────────────────────────────
|
// Execute:
|
||||||
|
let output = process::Command::new("sh").arg("-c").arg(exec).output().context("exec failed")?;
|
||||||
let output = process::Command::new("sh")
|
|
||||||
.arg("-c")
|
|
||||||
.arg(&args.exec)
|
|
||||||
.output()
|
|
||||||
.context("Failed to execute command")?;
|
|
||||||
|
|
||||||
let exit_code = output.status.code().unwrap_or(1);
|
let exit_code = output.status.code().unwrap_or(1);
|
||||||
let stdout_str = String::from_utf8_lossy(&output.stdout).to_string();
|
let stdout_str = String::from_utf8_lossy(&output.stdout).to_string();
|
||||||
let stderr_str = String::from_utf8_lossy(&output.stderr).to_string();
|
let stderr_str = String::from_utf8_lossy(&output.stderr).to_string();
|
||||||
|
|
||||||
// ── Post CR ─────────────────────────────────────────────
|
// Post CR (in session mode, this may fail on "already consumed" — that's a broker gap, not gsh's fault):
|
||||||
|
let outcome = if exit_code == 0 { "completed" } else { "failed" };
|
||||||
let outcome = if exit_code == 0 {
|
let (cr_id, chronicle_cid) = if !using_session {
|
||||||
"completed"
|
post_cr(&client, &base, &ac_id, outcome)
|
||||||
} else {
|
} else {
|
||||||
"failed"
|
// Session mode: post CR but accept failure gracefully
|
||||||
|
// (broker currently marks AC consumed after first CR)
|
||||||
|
let (id, cid) = post_cr(&client, &base, &ac_id, outcome);
|
||||||
|
if id.is_empty() && cid.is_empty() {
|
||||||
|
eprintln!("gsh: session CR not recorded (broker session support pending)");
|
||||||
|
}
|
||||||
|
(id, cid)
|
||||||
};
|
};
|
||||||
|
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
// Output:
|
||||||
|
|
||||||
let (cr_id, chronicle_cid) = match client
|
|
||||||
.post(format!(
|
|
||||||
"{}/governance/complete/",
|
|
||||||
broker_url.trim_end_matches('/')
|
|
||||||
))
|
|
||||||
.json(&CrRequest {
|
|
||||||
context_id: ac_id.clone(),
|
|
||||||
outcome: outcome.into(),
|
|
||||||
completed_at: now,
|
|
||||||
chronicle_evidence: CrEvidence {
|
|
||||||
events: vec![],
|
|
||||||
merkle_root: String::new(),
|
|
||||||
},
|
|
||||||
behavioral_attestation: CrAttestation {
|
|
||||||
status: "unavailable".into(),
|
|
||||||
},
|
|
||||||
ffc: serde_json::json!({"did": "did:web:guildhouse.dev"}),
|
|
||||||
signature: serde_json::json!({"value": "gsh-machine-mode"}),
|
|
||||||
})
|
|
||||||
.send()
|
|
||||||
{
|
|
||||||
Ok(resp) if resp.status().is_success() => {
|
|
||||||
let cr: CrResponse = resp.json().unwrap_or(CrResponse {
|
|
||||||
receipt_id: None,
|
|
||||||
chronicle_event_cid: None,
|
|
||||||
});
|
|
||||||
(
|
|
||||||
cr.receipt_id.unwrap_or_default(),
|
|
||||||
cr.chronicle_event_cid.unwrap_or_default(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Ok(resp) => {
|
|
||||||
eprintln!("gsh: CR failed: {}", resp.status());
|
|
||||||
(String::new(), String::new())
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("gsh: CR error: {}", e);
|
|
||||||
(String::new(), String::new())
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Output ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
if args.json {
|
if args.json {
|
||||||
println!(
|
println!("{}", serde_json::to_string_pretty(&GshOutput {
|
||||||
"{}",
|
exit_code, stdout: stdout_str, stderr: stderr_str,
|
||||||
serde_json::to_string_pretty(&GshOutput {
|
ac_id, session_ac: using_session, cr_id, chronicle_cid, command_hash, run_id,
|
||||||
exit_code,
|
})?);
|
||||||
stdout: stdout_str,
|
|
||||||
stderr: stderr_str,
|
|
||||||
ac_id,
|
|
||||||
cr_id,
|
|
||||||
chronicle_cid,
|
|
||||||
command_hash,
|
|
||||||
run_id,
|
|
||||||
})?
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
print!("{}", stdout_str);
|
print!("{}", stdout_str);
|
||||||
eprint!("{}", stderr_str);
|
eprint!("{}", stderr_str);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue