use super::error::{ApiError, ApiResult};
use super::{ServeState, blocking};
use crate::cli::{Cli, Command};
use axum::Json;
use axum::extract::State;
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use std::time::Duration;
const TIMEOUT: Duration = Duration::from_secs(120);
const MAX_OUTPUT: usize = 256 * 1024;
#[derive(Debug, Deserialize)]
pub struct CommandRequest {
pub line: String,
}
#[derive(Debug, Serialize)]
pub struct CommandResult {
pub argv: Vec<String>,
pub stdout: String,
pub stderr: String,
pub exit_code: i32,
pub truncated: bool,
}
pub async fn run(
State(st): State<Arc<ServeState>>,
Json(req): Json<CommandRequest>,
) -> ApiResult<Json<CommandResult>> {
let argv = tokenise(&req.line)
.map_err(ApiError::bad_request)?
.ok_or_else(|| ApiError::bad_request("there is no command in that line"))?;
if argv
.iter()
.any(|a| a == "--store" || a.starts_with("--store="))
{
return Err(ApiError::bad_request(
"`--store` is not accepted here: a command typed in this window runs against \
this window's store. Open the other project in its own window.",
));
}
let parsed = Cli::try_parse_from(std::iter::once("cyberbrain".to_string()).chain(argv.clone()))
.map_err(|e| ApiError::bad_request(e.to_string()))?;
if let Some(why) = refuse(&parsed.command) {
return Err(ApiError::bad_request(why));
}
let exe = st.self_exe.clone();
let root = st.app.root().to_path_buf();
let args = argv.clone();
let out = blocking(move || {
let mut child = std::process::Command::new(&exe)
.arg("--store")
.arg(&root)
.args(&args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| ApiError::internal(format!("the command could not be started: {e}")))?;
wait_with_timeout(&mut child)
})
.await?;
let (stdout, cut_out) = cut(out.stdout);
let (stderr, cut_err) = cut(out.stderr);
Ok(Json(CommandResult {
argv,
stdout,
stderr,
exit_code: out.code,
truncated: cut_out || cut_err,
}))
}
struct Finished {
stdout: Vec<u8>,
stderr: Vec<u8>,
code: i32,
}
fn wait_with_timeout(child: &mut std::process::Child) -> ApiResult<Finished> {
use std::io::Read;
let mut out = child.stdout.take();
let mut err = child.stderr.take();
let out = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(p) = out.as_mut() {
let _ = p.read_to_end(&mut buf);
}
buf
});
let err = std::thread::spawn(move || {
let mut buf = Vec::new();
if let Some(p) = err.as_mut() {
let _ = p.read_to_end(&mut buf);
}
buf
});
let deadline = std::time::Instant::now() + TIMEOUT;
let status = loop {
match child.try_wait() {
Ok(Some(s)) => break Some(s),
Ok(None) => {}
Err(e) => {
return Err(ApiError::internal(format!(
"the command could not be waited for: {e}"
)));
}
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
break None;
}
std::thread::sleep(Duration::from_millis(20));
};
let stdout = out.join().unwrap_or_default();
let mut stderr = err.join().unwrap_or_default();
match status {
Some(s) => Ok(Finished {
stdout,
stderr,
code: s.code().unwrap_or(-1),
}),
None => {
stderr.extend_from_slice(
format!(
"\ncyberbrain: no answer after {} seconds, so the command was stopped.\n",
TIMEOUT.as_secs()
)
.as_bytes(),
);
Ok(Finished {
stdout,
stderr,
code: -1,
})
}
}
}
fn cut(bytes: Vec<u8>) -> (String, bool) {
let cut = bytes.len() > MAX_OUTPUT;
let mut text = String::from_utf8_lossy(&bytes).into_owned();
if cut {
let mut end = MAX_OUTPUT.min(text.len());
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
text.push_str("\n…output cut here.\n");
}
(text, cut)
}
fn refuse(command: &Command) -> Option<&'static str> {
match command {
Command::Scan { .. }
| Command::Recall { .. }
| Command::Find { .. }
| Command::Write { .. }
| Command::Invalidate { .. }
| Command::Forget { .. }
| Command::Doctor
| Command::Status
| Command::Export { .. }
| Command::Propose { .. }
| Command::Review { .. } => None,
Command::Policy { command } => refuse_policy(command),
Command::Serve { .. } => {
Some("`serve` is what is answering this: the page you are reading is a running one.")
}
Command::Mcp => {
Some("`mcp` speaks a protocol over stdin and stdout, and there is no terminal here.")
}
Command::Daemon { .. } => Some(
"`daemon` is started by `recall` itself and runs until it has been idle; there is \
nothing to run by hand here.",
),
Command::Hook { .. } => {
Some("`hook` is called by an agent harness, with the session's payload on stdin.")
}
Command::Init { .. } => {
Some("`init` creates a store, and this window already has one open.")
}
Command::Install { .. } => Some(
"`install` writes into other programs' configuration files, which is not this \
store's business. It is in the launcher's menu, and at a prompt.",
),
Command::Import { .. } => Some(
"`import` reads a plan and every file it names from anywhere on disk. This \
surface has no authentication, so it is a prompt's job.",
),
Command::Manifest { .. } => Some(
"`manifest` writes a file into a folder you name, and this surface has no \
authentication. It is a one-off setup step; run it at a prompt.",
),
Command::VerifyExport { .. } => Some(
"`verify-export` reads a file somebody handed you, from wherever you put it. \
This surface has no authentication, so it is a prompt's job.",
),
Command::Hub { .. } => Some(
"`hub` is a different surface with an authentication of its own, and this is not it.",
),
}
}
fn refuse_policy(command: &crate::cli::PolicyCommand) -> Option<&'static str> {
use crate::cli::PolicyCommand as P;
match command {
P::Audit {
export: Some(_), ..
} => Some(
"`policy audit --export` writes a file wherever it is pointed, and this surface \
has no authentication. Read the log without it, or export it at a prompt.",
),
P::Audit { .. }
| P::Egress
| P::Obligations
| P::Subject { .. }
| P::Retention { .. }
| P::ModelCard
| P::Consent { .. } => None,
}
}
pub fn tokenise(line: &str) -> Result<Option<Vec<String>>, String> {
let mut out: Vec<String> = Vec::new();
let mut cur = String::new();
let mut has = false;
let mut quote: Option<char> = None;
let mut chars = line.chars().peekable();
while let Some(c) = chars.next() {
match (quote, c) {
(Some('\''), c) if c != '\'' => {
cur.push(c);
has = true;
}
(_, '\\') => {
match chars.peek() {
Some('"') | Some('\'') | Some('\\') => {
cur.push(chars.next().unwrap_or('\\'));
}
_ => cur.push('\\'),
}
has = true;
}
(Some(q), c) if c == q => quote = None,
(Some(_), c) => {
cur.push(c);
has = true;
}
(None, '\'') | (None, '"') => {
quote = Some(c);
has = true;
}
(None, c) if c.is_whitespace() => {
if has {
out.push(std::mem::take(&mut cur));
has = false;
}
}
(None, c) => {
cur.push(c);
has = true;
}
}
}
if quote.is_some() {
return Err("there is a quote in that line that is never closed".into());
}
if has {
out.push(cur);
}
Ok((!out.is_empty()).then_some(out))
}