blob: 64a2bade0d5e894ca48130dab874d6321ae329e5 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Durable shell-resource state, independent of invocation history and live handles.
mod state;
pub use maka_sandbox::Sandbox;
pub use state::{ShellOutcome, ShellState};
use crate::terminal::{TerminalScreen, display::Display};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
const MAX_SAFE: u64 = 9_007_199_254_740_991;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ShellVisibility {
Model,
User,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PipeStream {
Stdout,
Stderr,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)]
pub enum ShellOutput {
Pipes {
stdout: String,
stderr: String,
latest_stream: Option<PipeStream>,
stdout_truncated: bool,
stderr_truncated: bool,
},
Pty {
screen: Arc<Display>,
},
}
impl ShellOutput {
pub fn is_pty(&self) -> bool {
matches!(self, Self::Pty { .. })
}
fn validate(&self) -> Result<(), &'static str> {
if let Self::Pty { screen } = self {
screen.validate()?;
}
Ok(())
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ShellRun {
pub id: String,
pub session_id: String,
pub source_run_id: Option<String>,
pub source_turn_id: String,
pub source_tool_call_id: String,
pub visibility: ShellVisibility,
pub permissions: ShellPermissions,
pub cwd: String,
pub command: String,
pub started_at: u64,
pub updated_at: u64,
pub timeout_ms: Option<u64>,
pub revision: u64,
pub state: ShellState,
pub output: ShellOutput,
}
/// Launch authority remains immutable when a Session's policy changes.
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ShellPermissions {
pub boundary_revision: u64,
pub sandbox: Sandbox,
}
/// Only mutable facts may be patched. State and final output share one commit.
#[derive(Default)]
pub struct ShellPatch {
pub state: Option<ShellState>,
pub output: Option<ShellOutput>,
pub observed_at: Option<u64>,
pub updated_at: Option<u64>,
}
impl ShellRun {
pub fn validate(&self) -> Result<(), &'static str> {
self.validate_metadata()?;
self.output.validate()
}
/// Metadata projections never scan the retained display. Full validation
/// belongs at persistence/decoding boundaries.
pub fn validate_metadata(&self) -> Result<(), &'static str> {
if self.permissions.boundary_revision > MAX_SAFE {
return Err("invalid shell permission revision");
}
if let maka_sandbox::Sandbox::Managed { filesystem, .. } = &self.permissions.sandbox {
filesystem
.compile()
.map_err(|_| "invalid shell launch policy")?;
}
crate::interaction::entity_id(&self.id)?;
crate::interaction::entity_id(&self.session_id)?;
crate::interaction::entity_id(&self.source_turn_id)?;
if let Some(id) = &self.source_run_id {
crate::interaction::entity_id(id)?;
}
if self.source_tool_call_id.is_empty()
|| self.source_tool_call_id.len() > 512
|| self.cwd.contains('\0')
|| self.command.contains('\0')
|| self.revision == 0
|| self.revision > MAX_SAFE
{
return Err("invalid shell resource metadata");
}
time(self.started_at)?;
time(self.updated_at)?;
if let Some(timeout) = self.timeout_ms {
time(timeout)?;
}
self.state.validate()
}
pub fn patched(&self, patch: ShellPatch) -> Result<Self, &'static str> {
let mut next = self.clone();
if let Some(state) = patch.state {
if !self.state.accepts(&state) {
return Err("invalid shell state transition");
}
next.state = state;
}
if let Some(output) = patch.output {
if self.output.is_pty() != output.is_pty() {
return Err("shell output mode is immutable");
}
next.output = output;
}
if let Some(at) = patch.observed_at {
time(at)?;
match &mut next.state {
ShellState::Terminal { observed_at, .. } => {
observed_at.get_or_insert(at);
}
_ => return Err("active shell cannot be observed as complete"),
}
}
if let Some(at) = patch.updated_at {
next.updated_at = at;
}
next.validate()?;
if next != *self {
next.revision = self
.revision
.checked_add(1)
.filter(|r| *r <= MAX_SAFE)
.ok_or("shell revision exhausted")?;
}
Ok(next)
}
}
fn time(value: u64) -> Result<(), &'static str> {
if value > MAX_SAFE {
Err("invalid shell time")
} else {
Ok(())
}
}
/// One published observation: committed metadata/display plus a bounded live
/// PTY text projection. The preview is never a persisted display or parser seed.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ShellObservation {
pub record: Arc<ShellRun>,
pub preview: Option<Arc<TerminalScreen>>,
}
impl From<ShellRun> for ShellObservation {
fn from(record: ShellRun) -> Self {
Self {
record: Arc::new(record),
preview: None,
}
}
}
impl std::ops::Deref for ShellObservation {
type Target = ShellRun;
fn deref(&self) -> &ShellRun {
&self.record
}
}