diff --git a/.dockerignore b/.dockerignore index e9c33aa0..81d2c9b7 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,6 +7,7 @@ target/* !target/release/ target/release/* !target/release/openplotva-app +!target/release/openplotva-sandbox *.log *.tmp .env diff --git a/.env.example b/.env.example index 32058e09..bae54a6f 100644 --- a/.env.example +++ b/.env.example @@ -262,6 +262,13 @@ # PLOTVA_UPLOADER_SECRET= # PLOTVA_UPLOADER_TIMEOUT_SECONDS=120 +# ----------------------------------------------------------------------------- +# Python sandbox (run_python dialog tool; see sandbox/) +# ----------------------------------------------------------------------------- +# Supervisor base URL; the tool stays hidden from the model while it is empty. +# SANDBOX_URL=http://sandbox:8090 +# SANDBOX_TIMEOUT_SECONDS=75 + # ----------------------------------------------------------------------------- # Vision # ----------------------------------------------------------------------------- diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 3901f5e1..233808d8 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -44,6 +44,7 @@ jobs: image_name="ghcr.io/${GITHUB_REPOSITORY}" image_name="$(printf '%s' "$image_name" | tr '[:upper:]' '[:lower:]')" echo "OPENPLOTVA_IMAGE=${image_name}:${GITHUB_SHA}" >> "$GITHUB_ENV" + echo "OPENPLOTVA_SANDBOX_IMAGE=${image_name}-sandbox:${GITHUB_SHA}" >> "$GITHUB_ENV" - name: Install Rust run: | @@ -55,8 +56,8 @@ jobs: with: shared-key: rust-${{ runner.os }}-ubuntu-22.04-${{ env.RUST_VERSION }}-deploy - - name: Build release binary - run: cargo build --locked --release -p openplotva-app + - name: Build release binaries + run: cargo build --locked --release -p openplotva-app -p openplotva-sandbox - name: Log in to GHCR uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 @@ -84,6 +85,23 @@ jobs: cache-from: type=gha,scope=openplotva-runtime-prebuilt cache-to: type=gha,mode=min,scope=openplotva-runtime-prebuilt + - name: Build and push sandbox image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 + env: + DOCKER_BUILD_RECORD_UPLOAD: "false" + with: + context: . + file: sandbox/Dockerfile + target: runtime-prebuilt + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=https://github.com/${{ github.repository }} + tags: ${{ env.OPENPLOTVA_SANDBOX_IMAGE }} + push: true + # mode=max keeps the nsjail and package layers between deploys. + cache-from: type=gha,scope=openplotva-sandbox + cache-to: type=gha,mode=max,scope=openplotva-sandbox + - name: Wait for green CI on this commit shell: bash env: @@ -173,6 +191,7 @@ jobs: set -euo pipefail test -n "$GHCR_PULL_TOKEN" printf -v image_q '%q' "$OPENPLOTVA_IMAGE" + printf -v sandbox_image_q '%q' "$OPENPLOTVA_SANDBOX_IMAGE" printf -v token_q '%q' "$GHCR_PULL_TOKEN" printf -v actor_q '%q' "$GITHUB_ACTOR" printf -v env_q '%q' "${OPENPLOTVA_PRODUCTION_ENV_B64:-}" @@ -180,7 +199,7 @@ jobs: -o ServerAliveInterval=30 \ -o ServerAliveCountMax=20 \ "$GETA_SSH_TARGET" \ - "OPENPLOTVA_DEPLOY_IMAGE=$image_q GHCR_USERNAME=$actor_q GHCR_PULL_TOKEN=$token_q OPENPLOTVA_PRODUCTION_ENV_B64=$env_q '$GETA_DEPLOY_ROOT/deploy-production.sh'" + "OPENPLOTVA_DEPLOY_IMAGE=$image_q OPENPLOTVA_SANDBOX_IMAGE=$sandbox_image_q GHCR_USERNAME=$actor_q GHCR_PULL_TOKEN=$token_q OPENPLOTVA_PRODUCTION_ENV_B64=$env_q '$GETA_DEPLOY_ROOT/deploy-production.sh'" - name: Remove non-current GHCR images shell: bash @@ -194,27 +213,29 @@ jobs: } owner="${GITHUB_REPOSITORY_OWNER,,}" - package="${GITHUB_REPOSITORY#*/}" - package="${package,,}" + repository="${GITHUB_REPOSITORY#*/}" + repository="${repository,,}" current_tag="${GITHUB_SHA}" - versions_file="$(mktemp)" - echo "Cleaning GHCR package ${owner}/${package}; keeping only current tag ${current_tag}" - gh api --paginate --slurp "/users/${owner}/packages/container/${package}/versions?per_page=100" > "$versions_file" + for package in "$repository" "${repository}-sandbox"; do + versions_file="$(mktemp)" + echo "Cleaning GHCR package ${owner}/${package}; keeping only current tag ${current_tag}" + gh api --paginate --slurp "/users/${owner}/packages/container/${package}/versions?per_page=100" > "$versions_file" - deleted=0 - while IFS= read -r version; do - id="$(jq -r '.id' <<<"$version")" - created_at="$(jq -r '.created_at' <<<"$version")" - tags="$(jq -r '[.metadata.container.tags[]?] | join(",")' <<<"$version")" + deleted=0 + while IFS= read -r version; do + id="$(jq -r '.id' <<<"$version")" + created_at="$(jq -r '.created_at' <<<"$version")" + tags="$(jq -r '[.metadata.container.tags[]?] | join(",")' <<<"$version")" - if jq -e --arg tag "$current_tag" '.metadata.container.tags // [] | index($tag)' <<<"$version" >/dev/null; then - continue - fi + if jq -e --arg tag "$current_tag" '.metadata.container.tags // [] | index($tag)' <<<"$version" >/dev/null; then + continue + fi - echo "Deleting GHCR version id=${id} created_at=${created_at} tags=${tags:-}" - gh api -X DELETE "/users/${owner}/packages/container/${package}/versions/${id}" - deleted=$((deleted + 1)) - done < <(jq -c '.[][]' "$versions_file") + echo "Deleting GHCR version id=${id} created_at=${created_at} tags=${tags:-}" + gh api -X DELETE "/users/${owner}/packages/container/${package}/versions/${id}" + deleted=$((deleted + 1)) + done < <(jq -c '.[][]' "$versions_file") - echo "Deleted ${deleted} old GHCR package version(s)" + echo "Deleted ${deleted} old version(s) of ${package}" + done diff --git a/AGENTS.md b/AGENTS.md index e5a65f92..290aadbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Codebase Overview -OpenPlotva is a Telegram bot + web service runtime in Rust (18-crate workspace, edition 2024), migrated from a Go original (`go-plotva`): agentic AI dialog with tool-calling turns, media generation (image/music/vision/youtube), bitemporal memory, content-safety shield, Telegram Stars payments, a token-driven admin Web UI, and an operator GraphQL runtime API. Single process backed by Postgres 17 + pgvector and Dragonfly (Redis). +OpenPlotva is a Telegram bot + web service runtime in Rust (19-crate workspace, edition 2024), migrated from a Go original (`go-plotva`): agentic AI dialog with tool-calling turns, media generation (image/music/vision/youtube), bitemporal memory, content-safety shield, Telegram Stars payments, a token-driven admin Web UI, and an operator GraphQL runtime API. Single process backed by Postgres 17 + pgvector and Dragonfly (Redis). **Stack**: Rust 1.99.0 · tokio · axum · async-graphql · sqlx 0.9 (Postgres-only) · carapax (Telegram) · pgvector · redis · rustls · handlebars (prompts). diff --git a/Cargo.lock b/Cargo.lock index 982556c4..c8fd224c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2120,6 +2120,7 @@ dependencies = [ "openplotva-memory", "openplotva-observability", "openplotva-prompts", + "openplotva-sandbox", "openplotva-server", "openplotva-shield", "openplotva-storage", @@ -2262,6 +2263,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "openplotva-sandbox" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + [[package]] name = "openplotva-server" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 8a26997f..9261147f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ members = [ "crates/openplotva-memory", "crates/openplotva-shield", "crates/openplotva-media", + "crates/openplotva-sandbox", "crates/openplotva-web", ] default-members = ["crates/openplotva-app"] diff --git a/README.md b/README.md index 2126d823..0601c28b 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,7 @@ OPENPLOTVA_PRODUCE_UPDATES=false OPENPLOTVA_CONSUME_UPDATES=true cargo run -p op - `crates/openplotva-memory`: memory extraction and retrieval. - `crates/openplotva-observability`: tracing and log buffering. - `crates/openplotva-prompts`: prompt loading and rendering. +- `crates/openplotva-sandbox`: nsjail Python sandbox supervisor and its client. - `crates/openplotva-server`: HTTP, readiness, and runtime API types. - `crates/openplotva-shield`: protective retrieval and safety checks. - `crates/openplotva-storage`: Postgres, Redis, migrations, and persistence stores. @@ -116,4 +117,5 @@ OPENPLOTVA_PRODUCE_UPDATES=false OPENPLOTVA_CONSUME_UPDATES=true cargo run -p op - `crates/openplotva-telegram`: Telegram Bot API transport and outbound payloads. - `crates/openplotva-updates`: update queue codec and helpers. - `crates/openplotva-web`: admin/settings WebApp helpers and embedded assets. +- `sandbox`: Python sandbox image, package set, and nsjail policy. - `tools`: local smoke checks and auxiliary service binaries. diff --git a/crates/openplotva-app/Cargo.toml b/crates/openplotva-app/Cargo.toml index 42dc5350..a20446f5 100644 --- a/crates/openplotva-app/Cargo.toml +++ b/crates/openplotva-app/Cargo.toml @@ -22,6 +22,7 @@ openplotva-memory = { path = "../openplotva-memory" } openplotva-history = { path = "../openplotva-history" } openplotva-observability = { path = "../openplotva-observability" } openplotva-prompts = { path = "../openplotva-prompts" } +openplotva-sandbox = { path = "../openplotva-sandbox" } openplotva-server = { path = "../openplotva-server" } openplotva-shield = { path = "../openplotva-shield" } openplotva-storage = { path = "../openplotva-storage" } diff --git a/crates/openplotva-app/src/dialog_messages.rs b/crates/openplotva-app/src/dialog_messages.rs index 2f7ba4ba..9ba43993 100644 --- a/crates/openplotva-app/src/dialog_messages.rs +++ b/crates/openplotva-app/src/dialog_messages.rs @@ -666,7 +666,7 @@ impl DirectDrawApiRuntimeEffects { async fn record_sent_history(&self, message: carapax::types::Message) -> Option { let history = self.history.as_ref()?; - let entry = direct_draw_api_sent_history_entry(&message, self.bot_id)?; + let entry = sent_message_history_entry(&message, self.bot_id)?; history.record_direct_draw_api_history(entry).await.err() } } @@ -2087,7 +2087,8 @@ fn direct_draw_api_photo_request( }) } -fn direct_draw_api_sent_history_entry( +/// History entry for a message the bot sent itself, outside the dispatcher. +pub(crate) fn sent_message_history_entry( message: &carapax::types::Message, bot_id: i64, ) -> Option { diff --git a/crates/openplotva-app/src/dialog_tools.rs b/crates/openplotva-app/src/dialog_tools.rs index 62fb9a10..05986ecf 100644 --- a/crates/openplotva-app/src/dialog_tools.rs +++ b/crates/openplotva-app/src/dialog_tools.rs @@ -12,11 +12,12 @@ use openplotva_core::{ }; use openplotva_dialog::{ DialogToolbox, DrawRequest, HistorySearchRequest, HistorySummaryRequest, - IMAGE_GENERATION_NOT_SCHEDULED_MESSAGE, RatesRequest, SONG_GENERATION_NOT_SCHEDULED_MESSAGE, - SongRequest, TOOL_RESULT_STATUS_EXECUTED, TOOL_RESULT_STATUS_FAILED, TOOL_RESULT_STATUS_NOOP, - TOOL_RESULT_STATUS_OK, TOOL_RESULT_STATUS_QUEUED, ToolError, ToolResult, ToolSideEffect, - ToolboxError, ToolboxFuture, VisionRequest, is_internal_not_scheduled_instruction, - sanitize_tool_text, user_facing_not_scheduled_message, + IMAGE_GENERATION_NOT_SCHEDULED_MESSAGE, PythonRunRequest, RatesRequest, + SONG_GENERATION_NOT_SCHEDULED_MESSAGE, SongRequest, TOOL_RESULT_STATUS_EXECUTED, + TOOL_RESULT_STATUS_FAILED, TOOL_RESULT_STATUS_NOOP, TOOL_RESULT_STATUS_OK, + TOOL_RESULT_STATUS_QUEUED, ToolError, ToolResult, ToolSideEffect, ToolboxError, ToolboxFuture, + VisionRequest, is_internal_not_scheduled_instruction, sanitize_tool_text, + user_facing_not_scheduled_message, }; use openplotva_history::{StoredSummary, SummaryContent}; use openplotva_taskman::{ @@ -2272,6 +2273,7 @@ pub struct AppDialogToolbox { history_summarizer: Option>, history_searcher: Option>, agent_tools: Option>, + python_sandbox: Option>, } impl @@ -2298,6 +2300,7 @@ impl history_summarizer: None, history_searcher: None, agent_tools: None, + python_sandbox: None, } } @@ -2378,6 +2381,15 @@ impl self.history_searcher = Some(history_searcher); self } + + #[must_use] + pub fn with_python_sandbox( + mut self, + python_sandbox: Arc, + ) -> Self { + self.python_sandbox = Some(python_sandbox); + self + } } impl DialogToolbox @@ -2387,6 +2399,22 @@ where RatesDispatcherT: RatesSideEffectDispatcher + Send + Sync + 'static, TranslatorT: TextTranslator + Send + Sync + 'static, { + fn offers_tool(&self, name: &str) -> bool { + name != openplotva_dialog::STEP_RUN_PYTHON || self.python_sandbox.is_some() + } + + fn run_python<'a>(&'a self, req: PythonRunRequest) -> ToolboxFuture<'a> { + Box::pin(async move { + Ok(match self.python_sandbox.as_deref() { + Some(sandbox) => sandbox.run(req).await, + None => ToolResult::failed( + "run_python_unavailable", + "The Python sandbox is not configured", + ), + }) + }) + } + fn currency_rates<'a>(&'a self, req: RatesRequest) -> ToolboxFuture<'a> { Box::pin(async move { let fetcher = self.rates_fetcher.as_deref(); diff --git a/crates/openplotva-app/src/dialog_turn/session.rs b/crates/openplotva-app/src/dialog_turn/session.rs index 6fb07870..978edf90 100644 --- a/crates/openplotva-app/src/dialog_turn/session.rs +++ b/crates/openplotva-app/src/dialog_turn/session.rs @@ -294,11 +294,18 @@ fn inline_link_targets(answer: &str) -> Vec { /// serves plus the engine-intercepted tools. `send_message` and /// `react_to_message` deliberately never enter the shared catalog constant — /// the legacy provider loop must not advertise tools it cannot execute. -fn session_native_tools(allow_finish: bool) -> Result, String> { +fn session_native_tools( + toolbox: &dyn DialogToolbox, + allow_finish: bool, +) -> Result, String> { let names = openplotva_dialog::alternative_dialog_tool_names(); let mut specs = openplotva_dialog::alternative_dialog_tools() .into_iter() - .filter(|spec| names.contains(&spec.name) && (allow_finish || spec.name != "finish_turn")) + .filter(|spec| { + names.contains(&spec.name) + && toolbox.offers_tool(spec.name) + && (allow_finish || spec.name != "finish_turn") + }) .collect::>(); specs.push(SESSION_SEND_MESSAGE_SPEC); specs.push(SESSION_REACT_TO_MESSAGE_SPEC); @@ -487,6 +494,7 @@ where let active_params = crate::dialog_jobs::dialog_job_params_from_input(ctx.params, &base_input); let meta = dialog_tool_context(&base_input); let native_tools = match session_native_tools( + cfg.toolbox, active_params .meta .get("dialog_trigger") @@ -2949,20 +2957,46 @@ mod tests { } } + struct NoPythonToolbox; + + impl DialogToolbox for NoPythonToolbox { + fn offers_tool(&self, name: &str) -> bool { + name != openplotva_dialog::STEP_RUN_PYTHON + } + } + #[test] fn session_native_tools_exclude_agent_only_tools() { - let tools = session_native_tools(true).expect("native tool schemas"); - let names = tools - .iter() - .filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str)) - .collect::>(); + let tool_names = |toolbox: &dyn DialogToolbox| { + session_native_tools(toolbox, true) + .expect("native tool schemas") + .iter() + .filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str)) + .map(str::to_owned) + .collect::>() + }; + let names = tool_names(&BudgetProbe::default()); - assert!(names.contains(&openplotva_dialog::STEP_MEMORY_SEARCH)); - assert!(names.contains(&STEP_SEND_MESSAGE)); - assert!(names.contains(&STEP_REACT_TO_MESSAGE)); - assert!(names.contains(&"finish_turn")); assert!( - session_native_tools(false) + names + .iter() + .any(|name| name == openplotva_dialog::STEP_MEMORY_SEARCH) + ); + assert!(names.iter().any(|name| name == STEP_SEND_MESSAGE)); + assert!(names.iter().any(|name| name == STEP_REACT_TO_MESSAGE)); + assert!(names.iter().any(|name| name == "finish_turn")); + assert!( + names + .iter() + .any(|name| name == openplotva_dialog::STEP_RUN_PYTHON) + ); + assert!( + !tool_names(&NoPythonToolbox) + .iter() + .any(|name| name == openplotva_dialog::STEP_RUN_PYTHON) + ); + assert!( + session_native_tools(&BudgetProbe::default(), false) .expect("addressed schemas") .iter() .all(|tool| { diff --git a/crates/openplotva-app/src/lib.rs b/crates/openplotva-app/src/lib.rs index 9f1be958..9dae615e 100644 --- a/crates/openplotva-app/src/lib.rs +++ b/crates/openplotva-app/src/lib.rs @@ -43,6 +43,7 @@ pub mod music_jobs; pub mod openrouter_free_pool; pub mod payments; pub mod permissions; +pub mod python_sandbox; pub mod rate_limits; pub mod rates; pub mod reactions; @@ -12821,6 +12822,41 @@ async fn start_runtime_workers( .with_web_searcher(web_searcher) .with_url_crawler(url_crawler); } + if config.sandbox.url.is_empty() { + readiness_checks.push(ReadinessCheck::skipped( + "python_sandbox", + "SANDBOX_URL is empty; run_python is not offered", + )); + } else { + match openplotva_sandbox::client::SandboxClient::new( + &config.sandbox.url, + Duration::from_secs(u64::try_from(config.sandbox.timeout_seconds.max(1)).unwrap_or(75)), + ) { + Ok(client) => { + let io = Arc::new( + python_sandbox::TelegramSandboxIo::new( + telegram.clone(), + PostgresTelegramFileStore::new(service_clients.postgres.clone()), + ) + .with_history_store(Arc::new(history_store.clone()), bot_identity.id), + ); + app_dialog_toolbox = app_dialog_toolbox.with_python_sandbox(Arc::new( + python_sandbox::PythonSandboxTool::new(Arc::new(client), io.clone(), io), + )); + readiness_checks.push(ReadinessCheck::ok( + "python_sandbox", + "run_python uses the sandbox supervisor", + )); + } + Err(error) => { + tracing::warn!(%error, "Python sandbox client unavailable"); + readiness_checks.push(ReadinessCheck::skipped( + "python_sandbox", + format!("Python sandbox client unavailable: {error}"), + )); + } + } + } let dialog_toolbox: Arc = Arc::new(app_dialog_toolbox); let gradius_for_dialog = if !config.gradius.enabled { readiness_checks.push(ReadinessCheck::skipped( diff --git a/crates/openplotva-app/src/python_sandbox.rs b/crates/openplotva-app/src/python_sandbox.rs new file mode 100644 index 00000000..fd06e9e2 --- /dev/null +++ b/crates/openplotva-app/src/python_sandbox.rs @@ -0,0 +1,1425 @@ +//! The `run_python` dialog tool. +//! +//! The model's script runs in the sandbox supervisor next to the bot. Chat +//! files are copied into the session home before the run, and the files the +//! model names in `send_files` are uploaded to the chat after a clean exit. + +use std::{collections::BTreeSet, future::Future, path::Path, pin::Pin, sync::Arc}; + +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use futures_util::StreamExt; +use openplotva_dialog::{PythonRunRequest, TOOL_RESULT_STATUS_OK, ToolContext, ToolResult}; +use openplotva_sandbox::api::{InputFile, RunRequest, RunResponse, Termination}; +use openplotva_sandbox::client::{SandboxClient, SandboxClientError}; +use openplotva_storage::{PostgresTelegramFileStore, TelegramFileRecord}; +use openplotva_telegram::{ + AudioMessageRequest, AudioSource, ChatRef, MediaGroupMessageRequest, MediaGroupPhotoItem, + PhotoMessageRequest, PhotoSource, ReplyParametersPlan, TelegramClient, UploadMessageRequest, +}; +use serde::Serialize; +use serde_json::{Map, Value, json}; +use thiserror::Error; + +use crate::dialog_messages::DirectDrawApiHistoryStore; + +/// Largest file Telegram lets a bot download. +const MAX_INPUT_FILE_BYTES: u64 = 20 * 1024 * 1024; +/// Decoded input bytes per call; base64 keeps the request under the +/// supervisor's body limit. +const MAX_INPUT_TOTAL_BYTES: u64 = 64 * 1024 * 1024; +const MAX_INPUT_FILES: usize = 10; +/// Largest image Telegram accepts as a photo; bigger ones go as documents. +const MAX_PHOTO_BYTES: usize = 10_000_000; +const MAX_ALBUM_ITEMS: usize = 10; +const MAX_FILE_NAME_BYTES: usize = 120; +const CAPTION_MAX_CHARS: usize = 1024; +const STDOUT_MAX_CHARS: usize = 4000; +const STDERR_MAX_CHARS: usize = 3000; +const LISTED_FILES_MAX: usize = 30; +const ERROR_LINE_MAX_CHARS: usize = 300; + +/// Boxed future returned by the sandbox tool boundaries. +pub type SandboxFuture<'a, T> = Pin + Send + 'a>>; + +/// The supervisor API the tool needs. +pub trait SandboxBackend: Send + Sync { + fn run<'a>( + &'a self, + session: &'a str, + request: &'a RunRequest, + ) -> SandboxFuture<'a, Result>; + + fn read_file<'a>( + &'a self, + session: &'a str, + path: &'a str, + ) -> SandboxFuture<'a, Result, SandboxClientError>>; +} + +impl SandboxBackend for SandboxClient { + fn run<'a>( + &'a self, + session: &'a str, + request: &'a RunRequest, + ) -> SandboxFuture<'a, Result> { + Box::pin(SandboxClient::run(self, session, request)) + } + + fn read_file<'a>( + &'a self, + session: &'a str, + path: &'a str, + ) -> SandboxFuture<'a, Result, SandboxClientError>> { + Box::pin(SandboxClient::read_file(self, session, path)) + } +} + +/// A chat file downloaded for a run. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ChatFile { + /// Original file name, when the chat message carries one. + pub file_name: Option, + /// Telegram media kind, such as `photo` or `document`. + pub kind: String, + /// Extension of Telegram's stored file, without the dot. + pub extension: String, + pub bytes: Vec, +} + +#[derive(Debug, Error)] +pub enum ChatFileError { + #[error("the file is not available in this chat")] + NotFound, + #[error("the file has {size} bytes; Telegram lets the bot download at most {limit}")] + TooLarge { size: u64, limit: u64 }, + #[error("{0}")] + Failed(String), +} + +/// Resolves and downloads files the dialog references. +pub trait SandboxChatFiles: Send + Sync { + fn fetch<'a>( + &'a self, + context: &'a ToolContext, + reference: &'a str, + ) -> SandboxFuture<'a, Result>; +} + +/// A file the script created, read back from the session home. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct OutputFile { + pub path: String, + pub bytes: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +pub struct DeliveryError { + pub path: String, + pub error: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct DeliveryReport { + pub sent: Vec, + pub errors: Vec, +} + +/// Uploads output files to the chat of the dialog turn. +pub trait SandboxDelivery: Send + Sync { + fn deliver<'a>( + &'a self, + context: &'a ToolContext, + caption: &'a str, + files: Vec, + ) -> SandboxFuture<'a, DeliveryReport>; +} + +/// The `run_python` tool: chat files in, script run, output files out. +#[derive(Clone)] +pub struct PythonSandboxTool { + backend: Arc, + chat_files: Arc, + delivery: Arc, +} + +impl PythonSandboxTool { + #[must_use] + pub fn new( + backend: Arc, + chat_files: Arc, + delivery: Arc, + ) -> Self { + Self { + backend, + chat_files, + delivery, + } + } + + pub async fn run(&self, request: PythonRunRequest) -> ToolResult { + let context = &request.context; + if context.chat_id == 0 { + return ToolResult::failed( + "run_python_unavailable", + "This dialog has no chat to run code for.", + ); + } + let file_ids = unique_values(&request.file_ids); + if file_ids.len() > MAX_INPUT_FILES { + return ToolResult::failed( + "run_python_too_many_files", + format!("Pass at most {MAX_INPUT_FILES} file_ids."), + ); + } + let send_files = unique_values( + &request + .send_files + .iter() + .map(|path| home_relative(path).to_owned()) + .collect::>(), + ); + if send_files.len() > openplotva_dialog::MAX_SEND_FILES { + return ToolResult::failed( + "run_python_too_many_files", + format!( + "Pass at most {} send_files.", + openplotva_dialog::MAX_SEND_FILES + ), + ); + } + let inputs = match self.fetch_inputs(context, &file_ids).await { + Ok(inputs) => inputs, + Err(result) => return *result, + }; + let input_names = inputs.iter().map(|file| file.path.clone()).collect(); + let session = session_key(context); + let run_request = RunRequest { + code: request.code.clone(), + files: inputs, + }; + let response = match self.backend.run(&session, &run_request).await { + Ok(response) => response, + Err(SandboxClientError::Busy(_)) => { + return ToolResult::failed( + "sandbox_busy", + "Every sandbox slot is busy. Ask the user to try again in a minute.", + ); + } + Err(SandboxClientError::Rejected { code, message }) => { + return ToolResult::failed("run_python_rejected", format!("{code}: {message}")); + } + Err(error) => { + tracing::warn!(%error, chat_id = context.chat_id, "python sandbox call failed"); + return ToolResult::failed( + "sandbox_unavailable", + "The Python sandbox is unavailable right now.", + ); + } + }; + + let succeeded = + response.termination == Termination::Exited && response.exit_code == Some(0); + let mut report = DeliveryReport::default(); + if succeeded && !send_files.is_empty() { + let mut outputs = Vec::new(); + for path in &send_files { + match self.backend.read_file(&session, path).await { + Ok(bytes) if bytes.is_empty() => report.errors.push(DeliveryError { + path: path.clone(), + error: "the file is empty".to_owned(), + }), + Ok(bytes) => outputs.push(OutputFile { + path: path.clone(), + bytes, + }), + Err(error) => report.errors.push(DeliveryError { + path: path.clone(), + error: error.to_string(), + }), + } + } + if !outputs.is_empty() { + let delivered = self + .delivery + .deliver(context, request.caption.trim(), outputs) + .await; + report.sent = delivered.sent; + report.errors.extend(delivered.errors); + } + } + let skipped = if succeeded { Vec::new() } else { send_files }; + run_result(&response, input_names, report, skipped) + } + + async fn fetch_inputs( + &self, + context: &ToolContext, + file_ids: &[String], + ) -> Result, Box> { + let mut used_names = BTreeSet::new(); + let mut total_bytes = 0_u64; + let mut files = Vec::with_capacity(file_ids.len()); + for (index, reference) in file_ids.iter().enumerate() { + let file = self + .chat_files + .fetch(context, reference) + .await + .map_err(|error| { + let code = match error { + ChatFileError::NotFound => "run_python_file_not_found", + ChatFileError::TooLarge { .. } => "run_python_file_too_large", + ChatFileError::Failed(_) => "run_python_file_unavailable", + }; + Box::new(ToolResult::failed(code, format!("{reference}: {error}"))) + })?; + total_bytes = total_bytes.saturating_add(file.bytes.len() as u64); + if total_bytes > MAX_INPUT_TOTAL_BYTES { + return Err(Box::new(ToolResult::failed( + "run_python_file_too_large", + format!( + "The input files exceed {} MiB together.", + MAX_INPUT_TOTAL_BYTES / 1024 / 1024 + ), + ))); + } + let name = unique_name(input_file_name(&file, index), &mut used_names); + files.push(InputFile { + path: name, + content_base64: BASE64_STANDARD.encode(&file.bytes), + }); + } + Ok(files) + } +} + +/// One sandbox session per chat thread, so follow-up calls see earlier files. +fn session_key(context: &ToolContext) -> String { + match context.thread_id { + Some(thread_id) if thread_id != 0 => format!("c{}_t{thread_id}", context.chat_id), + _ => format!("c{}", context.chat_id), + } +} + +/// Scripts run in `/home`, so the model often writes absolute or `./` paths. +fn home_relative(path: &str) -> &str { + let path = path.trim(); + let path = path.strip_prefix("/home/").unwrap_or(path); + path.strip_prefix("./").unwrap_or(path) +} + +fn unique_values(values: &[String]) -> Vec { + let mut seen = BTreeSet::new(); + values + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty() && seen.insert(*value)) + .map(str::to_owned) + .collect() +} + +fn input_file_name(file: &ChatFile, index: usize) -> String { + if let Some(name) = file.file_name.as_deref().and_then(sanitize_file_name) { + return name; + } + let kind = sanitize_file_name(&file.kind).unwrap_or_else(|| "file".to_owned()); + let extension = file + .extension + .chars() + .filter(char::is_ascii_alphanumeric) + .collect::(); + let extension = if extension.is_empty() { + "bin".to_owned() + } else { + extension.to_ascii_lowercase() + }; + format!("{kind}_{}.{extension}", index + 1) +} + +/// Keep the base name only, without control characters or a leading dot, +/// so the supervisor accepts it as a plain file in the session home. +fn sanitize_file_name(name: &str) -> Option { + let base = name.rsplit(['/', '\\']).next().unwrap_or_default(); + let cleaned = base + .chars() + .map(|c| if c.is_control() || c == ':' { '_' } else { c }) + .collect::(); + let cleaned = cleaned.trim().trim_start_matches('.').trim(); + if cleaned.is_empty() { + return None; + } + if cleaned.len() <= MAX_FILE_NAME_BYTES { + return Some(cleaned.to_owned()); + } + let (stem, extension) = match cleaned.rsplit_once('.') { + Some((stem, extension)) if !stem.is_empty() && extension.len() <= 10 => { + (stem, Some(extension)) + } + _ => (cleaned, None), + }; + let budget = MAX_FILE_NAME_BYTES - extension.map_or(0, |extension| extension.len() + 1); + let mut end = budget.min(stem.len()); + while !stem.is_char_boundary(end) { + end -= 1; + } + Some(match extension { + Some(extension) => format!("{}.{extension}", &stem[..end]), + None => stem[..end].to_owned(), + }) +} + +fn unique_name(name: String, used: &mut BTreeSet) -> String { + if used.insert(name.clone()) { + return name; + } + let (stem, extension) = match name.rsplit_once('.') { + Some((stem, extension)) if !stem.is_empty() => (stem.to_owned(), format!(".{extension}")), + _ => (name.clone(), String::new()), + }; + (2..) + .map(|suffix| format!("{stem}_{suffix}{extension}")) + .find(|candidate| used.insert(candidate.clone())) + .unwrap_or(name) +} + +fn run_result( + response: &RunResponse, + inputs: Vec, + report: DeliveryReport, + skipped: Vec, +) -> ToolResult { + let mut data = Map::new(); + data.insert( + "session".to_owned(), + json!(if response.session.created { + "new" + } else { + "continued" + }), + ); + data.insert("termination".to_owned(), json!(response.termination)); + if let Some(exit_code) = response.exit_code { + data.insert("exit_code".to_owned(), json!(exit_code)); + } + data.insert("duration_ms".to_owned(), json!(response.duration_ms)); + if !response.stdout.is_empty() { + let (stdout, clipped) = clip_head(&response.stdout, STDOUT_MAX_CHARS); + data.insert("stdout".to_owned(), json!(stdout)); + if clipped || response.stdout_truncated { + data.insert("stdout_truncated".to_owned(), json!(true)); + } + } + if !response.stderr.is_empty() { + let (stderr, clipped) = clip_tail(&response.stderr, STDERR_MAX_CHARS); + data.insert("stderr".to_owned(), json!(stderr)); + if clipped || response.stderr_truncated { + data.insert("stderr_truncated".to_owned(), json!(true)); + } + } + if !response.files.is_empty() { + data.insert( + "files".to_owned(), + json!( + response + .files + .iter() + .take(LISTED_FILES_MAX) + .collect::>() + ), + ); + if response.files.len() > LISTED_FILES_MAX { + data.insert("files_truncated".to_owned(), json!(true)); + } + } + if !inputs.is_empty() { + data.insert("inputs".to_owned(), json!(inputs)); + } + if !report.sent.is_empty() { + data.insert("sent".to_owned(), json!(report.sent)); + } + if !report.errors.is_empty() { + data.insert("send_errors".to_owned(), json!(report.errors)); + } + if !skipped.is_empty() { + data.insert("send_skipped".to_owned(), json!(skipped)); + } + let data = Some(Value::Object(data)); + + let (code, reason) = match (response.termination, response.exit_code) { + (Termination::Exited, Some(0)) if report.errors.is_empty() => { + let message = if report.sent.is_empty() { + "The script finished.".to_owned() + } else { + format!( + "The script finished; the chat received {}.", + report.sent.join(", ") + ) + }; + return ToolResult { + status: TOOL_RESULT_STATUS_OK.to_owned(), + message, + data, + ..ToolResult::default() + }; + } + (Termination::Exited, Some(0)) => ( + "run_python_files_not_sent", + "The script finished, but some files were not sent; see send_errors.".to_owned(), + ), + (Termination::Exited, exit_code) => ( + "python_error", + last_error_line(&response.stderr).unwrap_or_else(|| { + format!( + "The script exited with code {}.", + exit_code.map_or_else(|| "unknown".to_owned(), |code| code.to_string()) + ) + }), + ), + (Termination::Timeout, _) => ( + "python_timeout", + "The script hit the time limit and was stopped.".to_owned(), + ), + (Termination::Killed, _) => ( + "python_killed", + "The script was killed, most likely for exceeding the memory limit.".to_owned(), + ), + (Termination::SandboxError, _) => ( + "sandbox_error", + "The sandbox could not start the script.".to_owned(), + ), + }; + ToolResult { + data, + ..ToolResult::failed(code, reason) + } +} + +fn clip_head(text: &str, max_chars: usize) -> (String, bool) { + match text.char_indices().nth(max_chars) { + Some((end, _)) => (text[..end].to_owned(), true), + None => (text.to_owned(), false), + } +} + +fn clip_tail(text: &str, max_chars: usize) -> (String, bool) { + let count = text.chars().count(); + if count <= max_chars { + return (text.to_owned(), false); + } + (text.chars().skip(count - max_chars).collect(), true) +} + +fn last_error_line(stderr: &str) -> Option { + stderr + .lines() + .rev() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(|line| clip_head(line, ERROR_LINE_MAX_CHARS).0) +} + +/// How one group of output files is sent. +#[derive(Clone, Debug, Eq, PartialEq)] +enum Delivery { + Photos(Vec), + Animation(OutputFile), + Video(OutputFile), + Audio(OutputFile), + Document(OutputFile), +} + +impl Delivery { + fn files(&self) -> Vec<&OutputFile> { + match self { + Self::Photos(files) => files.iter().collect(), + Self::Animation(file) + | Self::Video(file) + | Self::Audio(file) + | Self::Document(file) => { + vec![file] + } + } + } +} + +fn file_extension(path: &str) -> String { + Path::new(path) + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() +} + +/// Group consecutive photos into albums and pick a send method for the rest. +fn plan_deliveries(files: Vec) -> Vec { + let mut deliveries = Vec::new(); + let mut photos = Vec::new(); + for file in files { + let extension = file_extension(&file.path); + let is_photo = matches!(extension.as_str(), "png" | "jpg" | "jpeg" | "webp") + && file.bytes.len() <= MAX_PHOTO_BYTES; + if is_photo { + photos.push(file); + if photos.len() == MAX_ALBUM_ITEMS { + deliveries.push(Delivery::Photos(std::mem::take(&mut photos))); + } + continue; + } + if !photos.is_empty() { + deliveries.push(Delivery::Photos(std::mem::take(&mut photos))); + } + deliveries.push(match extension.as_str() { + "gif" => Delivery::Animation(file), + "mp4" => Delivery::Video(file), + "mp3" | "m4a" => Delivery::Audio(file), + _ => Delivery::Document(file), + }); + } + if !photos.is_empty() { + deliveries.push(Delivery::Photos(photos)); + } + deliveries +} + +fn caption_text(caption: &str) -> String { + clip_head(caption.trim(), CAPTION_MAX_CHARS).0 +} + +/// Telegram-backed chat file source and delivery for the tool. +#[derive(Clone)] +pub struct TelegramSandboxIo { + telegram: TelegramClient, + files: PostgresTelegramFileStore, + history: Option>, + bot_id: i64, +} + +impl TelegramSandboxIo { + #[must_use] + pub fn new(telegram: TelegramClient, files: PostgresTelegramFileStore) -> Self { + Self { + telegram, + files, + history: None, + bot_id: 0, + } + } + + #[must_use] + pub fn with_history_store( + mut self, + history: Arc, + bot_id: i64, + ) -> Self { + self.history = Some(history); + self.bot_id = bot_id; + self + } + + async fn lookup( + &self, + reference: &str, + chat_id: i64, + ) -> Result, ChatFileError> { + let storage = |error: openplotva_storage::StorageError| { + ChatFileError::Failed(format!("file lookup failed: {error}")) + }; + let record = match self.files.get_file(reference).await.map_err(storage)? { + Some(record) => Some(record), + None => self + .files + .get_file_by_latest_file_id(reference) + .await + .map_err(storage)?, + }; + Ok(record.filter(|record| crate::vision::telegram_file_seen_in_chat(record, chat_id))) + } + + async fn download(&self, file_id: &str) -> Result<(Vec, String), ChatFileError> { + let file: carapax::types::File = self + .telegram + .execute(carapax::types::GetFile::new(file_id)) + .await + .map_err(|error| ChatFileError::Failed(format!("getFile failed: {error}")))?; + if let Some(size) = file.file_size.and_then(|size| u64::try_from(size).ok()) + && size > MAX_INPUT_FILE_BYTES + { + return Err(ChatFileError::TooLarge { + size, + limit: MAX_INPUT_FILE_BYTES, + }); + } + let path = file + .file_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .ok_or_else(|| ChatFileError::Failed("Telegram returned no file path".to_owned()))? + .to_owned(); + let mut stream = self + .telegram + .download_file(&path) + .await + .map_err(|error| ChatFileError::Failed(format!("download failed: {error}")))?; + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk + .map_err(|error| ChatFileError::Failed(format!("download failed: {error}")))?; + let size = (bytes.len() + chunk.len()) as u64; + if size > MAX_INPUT_FILE_BYTES { + return Err(ChatFileError::TooLarge { + size, + limit: MAX_INPUT_FILE_BYTES, + }); + } + bytes.extend_from_slice(&chunk); + } + Ok((bytes, file_extension(&path))) + } + + async fn send( + &self, + target: &UploadTarget, + delivery: &Delivery, + caption: &str, + ) -> Result, String> { + let error = |error: carapax::api::ExecuteError| error.to_string(); + let build = |error: openplotva_telegram::OutboundBuildError| error.to_string(); + match delivery { + Delivery::Photos(files) if files.len() == 1 => { + let request = PhotoMessageRequest { + chat: target.chat, + message_thread_id: target.message_thread_id, + disable_notification: false, + photo: photo_source(&files[0]), + caption: caption.to_owned(), + render_as: String::new(), + has_spoiler: false, + reply_parameters: target.reply_parameters, + }; + let method = + openplotva_telegram::build_photo_message_method(&request).map_err(build)?; + self.telegram + .execute(method) + .await + .map(|message| vec![message]) + .map_err(error) + } + Delivery::Photos(files) => { + let request = MediaGroupMessageRequest { + chat: target.chat, + message_thread_id: target.message_thread_id, + disable_notification: false, + items: files + .iter() + .enumerate() + .map(|(index, file)| MediaGroupPhotoItem { + photo: photo_source(file), + caption: if index == 0 { + caption.to_owned() + } else { + String::new() + }, + render_as: String::new(), + has_spoiler: false, + }) + .collect(), + reply_parameters: target.reply_parameters, + }; + let method = openplotva_telegram::build_media_group_message_method(&request) + .map_err(build)?; + self.telegram.execute(method).await.map_err(error) + } + Delivery::Audio(file) => { + let request = AudioMessageRequest { + chat: target.chat, + message_thread_id: target.message_thread_id, + disable_notification: false, + audio: AudioSource::Bytes { + file_name: upload_file_name(file), + bytes: file.bytes.clone(), + }, + caption: caption.to_owned(), + render_as: String::new(), + reply_parameters: target.reply_parameters, + }; + let method = + openplotva_telegram::build_audio_message_method(&request).map_err(build)?; + self.telegram + .execute(method) + .await + .map(|message| vec![message]) + .map_err(error) + } + Delivery::Animation(file) => { + let method = openplotva_telegram::build_animation_message_method( + &target.upload(file, caption), + ) + .map_err(build)?; + self.telegram + .execute(method) + .await + .map(|message| vec![message]) + .map_err(error) + } + Delivery::Video(file) => { + let method = + openplotva_telegram::build_video_message_method(&target.upload(file, caption)) + .map_err(build)?; + self.telegram + .execute(method) + .await + .map(|message| vec![message]) + .map_err(error) + } + Delivery::Document(file) => { + let method = openplotva_telegram::build_document_message_method( + &target.upload(file, caption), + ) + .map_err(build)?; + self.telegram + .execute(method) + .await + .map(|message| vec![message]) + .map_err(error) + } + } + } + + async fn record_sent(&self, messages: &[carapax::types::Message]) { + for message in messages { + for file_ref in openplotva_updates::telegram_message_file_metadata_refs(message) { + let Some(params) = + crate::updates::telegram_file_metadata_upsert_from_ref(&file_ref) + else { + continue; + }; + if let Err(error) = self.files.upsert_metadata(¶ms).await { + tracing::debug!(%error, "failed to record sandbox output file metadata"); + } + } + let Some(history) = self.history.as_ref() else { + continue; + }; + let Some(entry) = + crate::dialog_messages::sent_message_history_entry(message, self.bot_id) + else { + continue; + }; + if let Err(error) = history.record_direct_draw_api_history(entry).await { + tracing::debug!(%error, "failed to record sandbox output in history"); + } + } + } +} + +impl SandboxChatFiles for TelegramSandboxIo { + fn fetch<'a>( + &'a self, + context: &'a ToolContext, + reference: &'a str, + ) -> SandboxFuture<'a, Result> { + Box::pin(async move { + let attachment = context + .message_meta + .attachments + .iter() + .chain(&context.image_attachments) + .find(|attachment| { + attachment.file_unique_id == reference || attachment.file_id == reference + }); + let (file_id, kind, unique_id) = match self.lookup(reference, context.chat_id).await? { + Some(record) => ( + record.latest_file_id, + record.media_kind, + record.file_unique_id, + ), + None => match attachment.filter(|attachment| !attachment.file_id.is_empty()) { + Some(attachment) => ( + attachment.file_id.clone(), + attachment.kind.clone(), + attachment.file_unique_id.clone(), + ), + None => return Err(ChatFileError::NotFound), + }, + }; + let file_name = context + .message_meta + .attachments + .iter() + .chain(&context.image_attachments) + .find(|attachment| attachment.file_unique_id == unique_id) + .or(attachment) + .map(|attachment| attachment.file_name.trim().to_owned()) + .filter(|name| !name.is_empty()); + let (bytes, extension) = self.download(&file_id).await?; + Ok(ChatFile { + file_name, + kind, + extension, + bytes, + }) + }) + } +} + +impl SandboxDelivery for TelegramSandboxIo { + fn deliver<'a>( + &'a self, + context: &'a ToolContext, + caption: &'a str, + files: Vec, + ) -> SandboxFuture<'a, DeliveryReport> { + Box::pin(async move { + let target = UploadTarget::new(context); + let (report, messages) = deliver_planned( + plan_deliveries(files), + caption_text(caption), + |delivery: Delivery, caption: String| async move { + self.send(&target, &delivery, &caption).await + }, + ) + .await; + self.record_sent(&messages).await; + report + }) + } +} + +/// Send each planned delivery and report exactly which paths reached the +/// chat. A photo group Telegram rejects is retried file by file as +/// documents; the caption goes on the first message that gets through. +async fn deliver_planned( + deliveries: Vec, + mut caption: String, + mut send: F, +) -> (DeliveryReport, Vec) +where + F: FnMut(Delivery, String) -> Fut, + Fut: Future, String>>, +{ + let mut report = DeliveryReport::default(); + let mut sent = Vec::new(); + for delivery in deliveries { + let paths = delivery + .files() + .into_iter() + .map(|file| file.path.clone()) + .collect::>(); + let attempts = match delivery { + Delivery::Photos(files) => { + match send(Delivery::Photos(files.clone()), caption.clone()).await { + Ok(messages) => vec![(paths, Ok(messages))], + Err(error) => { + tracing::debug!(%error, "photo send failed; retrying as documents"); + let mut attempts = Vec::with_capacity(files.len()); + for file in files { + let path = file.path.clone(); + let result = send(Delivery::Document(file), caption.clone()).await; + if result.is_ok() { + caption.clear(); + } + attempts.push((vec![path], result)); + } + attempts + } + } + } + other => vec![(paths, send(other, caption.clone()).await)], + }; + for (paths, result) in attempts { + match result { + Ok(messages) => { + sent.extend(messages); + report.sent.extend(paths); + caption.clear(); + } + Err(error) => report + .errors + .extend(paths.into_iter().map(|path| DeliveryError { + path, + error: error.clone(), + })), + } + } + } + (report, sent) +} + +/// Where uploads go: the dialog's chat and thread, as a reply to the trigger. +#[derive(Clone, Copy, Debug)] +struct UploadTarget { + chat: ChatRef, + message_thread_id: i64, + reply_parameters: Option, +} + +impl UploadTarget { + fn new(context: &ToolContext) -> Self { + Self { + chat: ChatRef { + direct_messages_topic_id: context.message_meta.direct_messages_topic_id, + id: context.chat_id, + is_forum: context.thread_id.is_some(), + }, + message_thread_id: context.thread_id.map(i64::from).unwrap_or_default(), + reply_parameters: (context.message_id > 0).then(|| ReplyParametersPlan { + message_id: i64::from(context.message_id), + chat_id: context.chat_id, + allow_sending_without_reply: true, + }), + } + } + + fn upload(&self, file: &OutputFile, caption: &str) -> UploadMessageRequest { + UploadMessageRequest { + chat: self.chat, + message_thread_id: self.message_thread_id, + disable_notification: false, + file_name: upload_file_name(file), + bytes: file.bytes.clone(), + caption: caption.to_owned(), + render_as: String::new(), + reply_parameters: self.reply_parameters, + } + } +} + +fn upload_file_name(file: &OutputFile) -> String { + sanitize_file_name(&file.path).unwrap_or_else(|| "file".to_owned()) +} + +fn photo_source(file: &OutputFile) -> PhotoSource { + PhotoSource::Bytes { + file_name: upload_file_name(file), + bytes: file.bytes.clone(), + } +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use openplotva_sandbox::api::{FileEntry, SessionInfo}; + + use super::*; + + fn output(path: &str, size: usize) -> OutputFile { + OutputFile { + path: path.to_owned(), + bytes: vec![1; size], + } + } + + fn context() -> ToolContext { + ToolContext { + chat_id: -10_042, + thread_id: Some(7), + message_id: 11, + ..ToolContext::default() + } + } + + #[derive(Default)] + struct FakeBackend { + response: RunResponse, + busy: bool, + files: Vec<(String, Vec)>, + calls: Mutex>, + } + + impl SandboxBackend for FakeBackend { + fn run<'a>( + &'a self, + session: &'a str, + request: &'a RunRequest, + ) -> SandboxFuture<'a, Result> { + self.calls + .lock() + .expect("calls") + .push((session.to_owned(), request.clone())); + let result = if self.busy { + Err(SandboxClientError::Busy("full".to_owned())) + } else { + Ok(self.response.clone()) + }; + Box::pin(async move { result }) + } + + fn read_file<'a>( + &'a self, + _session: &'a str, + path: &'a str, + ) -> SandboxFuture<'a, Result, SandboxClientError>> { + let result = self + .files + .iter() + .find(|(name, _)| name == path) + .map(|(_, bytes)| bytes.clone()) + .ok_or_else(|| SandboxClientError::NotFound { + code: "file_not_found".to_owned(), + message: path.to_owned(), + }); + Box::pin(async move { result }) + } + } + + struct FakeChatFiles; + + impl SandboxChatFiles for FakeChatFiles { + fn fetch<'a>( + &'a self, + _context: &'a ToolContext, + reference: &'a str, + ) -> SandboxFuture<'a, Result> { + let result = match reference { + "csv" => Ok(ChatFile { + file_name: Some("продажи.csv".to_owned()), + kind: "document".to_owned(), + extension: "csv".to_owned(), + bytes: b"a,b\n1,2\n".to_vec(), + }), + "photo" => Ok(ChatFile { + kind: "photo".to_owned(), + extension: "jpg".to_owned(), + bytes: vec![0xff, 0xd8], + ..ChatFile::default() + }), + _ => Err(ChatFileError::NotFound), + }; + Box::pin(async move { result }) + } + } + + #[derive(Default)] + struct FakeDelivery { + delivered: Mutex)>>, + } + + impl SandboxDelivery for FakeDelivery { + fn deliver<'a>( + &'a self, + _context: &'a ToolContext, + caption: &'a str, + files: Vec, + ) -> SandboxFuture<'a, DeliveryReport> { + let paths = files + .iter() + .map(|file| file.path.clone()) + .collect::>(); + self.delivered + .lock() + .expect("delivered") + .push((caption.to_owned(), paths.clone())); + Box::pin(async move { + DeliveryReport { + sent: paths, + errors: Vec::new(), + } + }) + } + } + + fn exited(code: i32) -> RunResponse { + RunResponse { + termination: Termination::Exited, + exit_code: Some(code), + stdout: "42\n".to_owned(), + files: vec![FileEntry { + path: "chart.png".to_owned(), + size: 3, + changed: true, + }], + session: SessionInfo { + created: true, + ..SessionInfo::default() + }, + ..RunResponse::default() + } + } + + fn sandbox_tool( + backend: FakeBackend, + ) -> (PythonSandboxTool, Arc, Arc) { + let backend = Arc::new(backend); + let delivery = Arc::new(FakeDelivery::default()); + let tool = + PythonSandboxTool::new(backend.clone(), Arc::new(FakeChatFiles), delivery.clone()); + (tool, backend, delivery) + } + + fn request(file_ids: &[&str], send_files: &[&str]) -> PythonRunRequest { + PythonRunRequest { + context: context(), + code: "print(42)".to_owned(), + file_ids: file_ids.iter().map(|id| (*id).to_owned()).collect(), + send_files: send_files.iter().map(|path| (*path).to_owned()).collect(), + caption: " График ".to_owned(), + } + } + + #[tokio::test] + async fn successful_run_uploads_inputs_and_delivers_requested_files() { + let (tool, backend, delivery) = sandbox_tool(FakeBackend { + response: exited(0), + files: vec![("chart.png".to_owned(), vec![1, 2, 3])], + ..FakeBackend::default() + }); + + let result = tool + .run(request( + &["csv", "photo", "csv"], + &["/home/chart.png", "./chart.png"], + )) + .await; + + assert_eq!(result.status, TOOL_RESULT_STATUS_OK); + let calls = backend.calls.lock().expect("calls"); + assert_eq!(calls[0].0, "c-10042_t7"); + let names = calls[0] + .1 + .files + .iter() + .map(|file| file.path.as_str()) + .collect::>(); + assert_eq!(names, vec!["продажи.csv", "photo_2.jpg"]); + assert_eq!( + *delivery.delivered.lock().expect("delivered"), + vec![("График".to_owned(), vec!["chart.png".to_owned()])] + ); + let data = result.data.expect("data"); + assert_eq!(data["session"], "new"); + assert_eq!(data["stdout"], "42\n"); + assert_eq!(data["sent"], json!(["chart.png"])); + assert_eq!(data["inputs"], json!(["продажи.csv", "photo_2.jpg"])); + } + + #[tokio::test] + async fn failed_script_reports_the_error_and_sends_nothing() { + let mut response = exited(1); + response.stderr = + "Traceback (most recent call last):\n File \"/sandbox/main.py\"\nNameError: name 'x' is not defined\n" + .to_owned(); + let (tool, _, delivery) = sandbox_tool(FakeBackend { + response, + ..FakeBackend::default() + }); + + let result = tool.run(request(&[], &["chart.png"])).await; + + let error = result.error.expect("error"); + assert_eq!(error.code, "python_error"); + assert_eq!(error.reason, "NameError: name 'x' is not defined"); + assert!(delivery.delivered.lock().expect("delivered").is_empty()); + let data = result.data.expect("data"); + assert_eq!(data["send_skipped"], json!(["chart.png"])); + assert_eq!(data["exit_code"], 1); + } + + #[tokio::test] + async fn missing_output_and_unknown_inputs_are_reported() { + let (tool, backend, _) = sandbox_tool(FakeBackend { + response: exited(0), + ..FakeBackend::default() + }); + let result = tool.run(request(&["nope"], &[])).await; + assert_eq!( + result.error.expect("error").code, + "run_python_file_not_found" + ); + assert!(backend.calls.lock().expect("calls").is_empty()); + + let result = tool.run(request(&[], &["missing.png"])).await; + assert_eq!( + result.error.expect("error").code, + "run_python_files_not_sent" + ); + assert_eq!( + result.data.expect("data")["send_errors"][0]["path"], + "missing.png" + ); + } + + #[tokio::test] + async fn busy_and_limit_terminations_map_to_tool_errors() { + let (tool, _, _) = sandbox_tool(FakeBackend { + busy: true, + ..FakeBackend::default() + }); + assert_eq!( + tool.run(request(&[], &[])).await.error.expect("error").code, + "sandbox_busy" + ); + + for (termination, code) in [ + (Termination::Timeout, "python_timeout"), + (Termination::Killed, "python_killed"), + (Termination::SandboxError, "sandbox_error"), + ] { + let (tool, _, _) = sandbox_tool(FakeBackend { + response: RunResponse { + termination, + ..RunResponse::default() + }, + ..FakeBackend::default() + }); + assert_eq!( + tool.run(request(&[], &[])).await.error.expect("error").code, + code + ); + } + } + + #[test] + fn deliveries_group_photos_into_albums_and_route_media_by_extension() { + let mut files = (0..12) + .map(|index| output(&format!("p{index}.png"), 10)) + .collect::>(); + files.push(output("big.jpg", MAX_PHOTO_BYTES + 1)); + files.push(output("a.gif", 1)); + files.push(output("v.mp4", 1)); + files.push(output("s.mp3", 1)); + files.push(output("report.xlsx", 1)); + files.push(output("last.webp", 1)); + + let kinds = plan_deliveries(files) + .iter() + .map(|delivery| match delivery { + Delivery::Photos(files) => format!("photos:{}", files.len()), + Delivery::Animation(file) => format!("animation:{}", file.path), + Delivery::Video(file) => format!("video:{}", file.path), + Delivery::Audio(file) => format!("audio:{}", file.path), + Delivery::Document(file) => format!("document:{}", file.path), + }) + .collect::>(); + + assert_eq!( + kinds, + vec![ + "photos:10", + "photos:2", + "document:big.jpg", + "animation:a.gif", + "video:v.mp4", + "audio:s.mp3", + "document:report.xlsx", + "photos:1", + ] + ); + } + + #[tokio::test] + async fn rejected_albums_fall_back_per_file_and_report_partial_delivery() { + let calls = Mutex::new(Vec::new()); + let deliveries = plan_deliveries(vec![ + output("a.png", 1), + output("b.png", 1), + output("c.png", 1), + output("d.csv", 1), + ]); + + let (report, messages) = deliver_planned( + deliveries, + "Подпись".to_owned(), + |delivery: Delivery, caption: String| { + let label = match &delivery { + Delivery::Photos(files) => format!("album:{}", files.len()), + Delivery::Document(file) => format!("document:{}", file.path), + _ => "other".to_owned(), + }; + calls + .lock() + .expect("calls") + .push(format!("{label}|{caption}")); + async move { + match label.as_str() { + "album:3" => Err("PHOTO_INVALID_DIMENSIONS".to_owned()), + "document:a.png" => Err("network".to_owned()), + _ => Ok(vec![label]), + } + } + }, + ) + .await; + + assert_eq!( + *calls.lock().expect("calls"), + vec![ + "album:3|Подпись", + "document:a.png|Подпись", + "document:b.png|Подпись", + "document:c.png|", + "document:d.csv|", + ] + ); + assert_eq!(report.sent, vec!["b.png", "c.png", "d.csv"]); + assert_eq!( + report.errors, + vec![DeliveryError { + path: "a.png".to_owned(), + error: "network".to_owned(), + }] + ); + assert_eq!(messages.len(), 3); + } + + #[test] + fn file_names_are_plain_unique_and_bounded() { + assert_eq!( + sanitize_file_name("../../etc/.passwd").as_deref(), + Some("passwd") + ); + assert_eq!( + sanitize_file_name("C:\\tmp\\a.csv").as_deref(), + Some("a.csv") + ); + assert_eq!(sanitize_file_name(" .. ").as_deref(), None); + let long = format!("{}.xlsx", "я".repeat(100)); + let bounded = sanitize_file_name(&long).expect("name"); + assert!(bounded.len() <= MAX_FILE_NAME_BYTES); + assert!(bounded.ends_with(".xlsx")); + + let mut used = BTreeSet::new(); + assert_eq!(unique_name("a.csv".to_owned(), &mut used), "a.csv"); + assert_eq!(unique_name("a.csv".to_owned(), &mut used), "a_2.csv"); + assert_eq!(unique_name("a.csv".to_owned(), &mut used), "a_3.csv"); + + let unnamed = ChatFile { + kind: "voice".to_owned(), + extension: "oga".to_owned(), + ..ChatFile::default() + }; + assert_eq!(input_file_name(&unnamed, 0), "voice_1.oga"); + } + + #[test] + fn output_is_clipped_for_the_model() { + let response = RunResponse { + termination: Termination::Exited, + exit_code: Some(0), + stdout: "x".repeat(STDOUT_MAX_CHARS + 5), + stderr: format!("{}tail", "y".repeat(STDERR_MAX_CHARS)), + ..RunResponse::default() + }; + let data = run_result(&response, Vec::new(), DeliveryReport::default(), Vec::new()) + .data + .expect("data"); + assert_eq!( + data["stdout"].as_str().expect("stdout").len(), + STDOUT_MAX_CHARS + ); + assert_eq!(data["stdout_truncated"], true); + assert!(data["stderr"].as_str().expect("stderr").ends_with("tail")); + assert_eq!(data["stderr_truncated"], true); + } + + #[test] + fn session_keys_follow_chat_threads() { + let mut context = context(); + assert_eq!(session_key(&context), "c-10042_t7"); + context.thread_id = None; + assert_eq!(session_key(&context), "c-10042"); + assert!(openplotva_sandbox::api::is_valid_session_key(&session_key( + &context + ))); + } +} diff --git a/crates/openplotva-app/src/runtime_virtual_dialog.rs b/crates/openplotva-app/src/runtime_virtual_dialog.rs index bb1aa81f..e2a2c737 100644 --- a/crates/openplotva-app/src/runtime_virtual_dialog.rs +++ b/crates/openplotva-app/src/runtime_virtual_dialog.rs @@ -743,6 +743,26 @@ impl DialogToolbox for RuntimeVirtualSafeToolbox { fn history_search<'a>(&'a self, req: HistorySearchRequest) -> ToolboxFuture<'a> { self.inner.history_search(req) } + + fn offers_tool(&self, name: &str) -> bool { + self.inner.offers_tool(name) + } + + /// Runs the script for real but keeps its files in the sandbox: a virtual + /// dialog has no Telegram chat to deliver them to. + fn run_python<'a>(&'a self, mut req: openplotva_dialog::PythonRunRequest) -> ToolboxFuture<'a> { + Box::pin(async move { + let withheld = std::mem::take(&mut req.send_files); + let mut result = self.inner.run_python(req).await?; + if let Some(Value::Object(data)) = result.data.as_mut() { + data.insert("tool_mode".to_owned(), json!("SAFE")); + if !withheld.is_empty() { + data.insert("send_withheld".to_owned(), json!(withheld)); + } + } + Ok(result) + }) + } } pub(crate) async fn run_runtime_virtual_dialog_cleanup_worker_until( diff --git a/crates/openplotva-app/src/vision.rs b/crates/openplotva-app/src/vision.rs index a7aadd90..459b931b 100644 --- a/crates/openplotva-app/src/vision.rs +++ b/crates/openplotva-app/src/vision.rs @@ -1197,7 +1197,7 @@ where } } -fn telegram_file_seen_in_chat(record: &TelegramFileRecord, chat_id: i64) -> bool { +pub(crate) fn telegram_file_seen_in_chat(record: &TelegramFileRecord, chat_id: i64) -> bool { chat_id != 0 && (record.first_seen_chat_id == Some(chat_id) || record.last_seen_chat_id == Some(chat_id)) } diff --git a/crates/openplotva-config/src/lib.rs b/crates/openplotva-config/src/lib.rs index c10d37f5..3ef52558 100644 --- a/crates/openplotva-config/src/lib.rs +++ b/crates/openplotva-config/src/lib.rs @@ -363,6 +363,9 @@ pub const DEFAULT_ACESTEP_MODEL: &str = "acemusic/acestep-v1.5-turbo"; /// Default media uploader request timeout, `PLOTVA_UPLOADER_TIMEOUT_SECONDS`. pub const DEFAULT_UPLOADER_TIMEOUT_SECONDS: i32 = 120; +/// Default Python sandbox request timeout, `SANDBOX_TIMEOUT_SECONDS`. +pub const DEFAULT_SANDBOX_TIMEOUT_SECONDS: i32 = 75; + pub const DEFAULT_DIALOG_NVIDIA_URL: &str = "https://integrate.api.nvidia.com/v1/chat/completions"; pub const DEFAULT_DIALOG_NVIDIA_MODEL: &str = "google/gemma-4-31b-it"; @@ -451,6 +454,8 @@ pub struct AppConfig { pub open_router: OpenRouterConfig, /// Media uploader configuration. pub uploader: UploaderConfig, + /// Python sandbox supervisor configuration. + pub sandbox: SandboxConfig, /// Telegram Stars payment configuration. pub payments: PaymentsConfig, pub white_circle: WhiteCircleConfig, @@ -776,6 +781,15 @@ pub struct UploaderConfig { pub timeout_seconds: i32, } +/// Python sandbox supervisor configuration. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct SandboxConfig { + /// Supervisor base URL, from `SANDBOX_URL`; empty disables the `run_python` tool. + pub url: String, + /// Request timeout, from `SANDBOX_TIMEOUT_SECONDS`. + pub timeout_seconds: i32, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct WhiteCircleConfig { pub enabled: bool, @@ -1323,6 +1337,10 @@ pub struct RawConfig { pub uploader_secret: Option, /// `PLOTVA_UPLOADER_TIMEOUT_SECONDS`. pub uploader_timeout_seconds: Option, + /// `SANDBOX_URL`. + pub sandbox_url: Option, + /// `SANDBOX_TIMEOUT_SECONDS`. + pub sandbox_timeout_seconds: Option, /// `PAYMENTS_SUPPORT_URL`. pub payments_support_url: Option, /// `WHITECIRCLE_ENABLED`. @@ -2483,6 +2501,17 @@ impl AppConfig { DEFAULT_UPLOADER_TIMEOUT_SECONDS, )?, }, + sandbox: SandboxConfig { + url: raw + .sandbox_url + .map(|value| value.trim().to_owned()) + .unwrap_or_default(), + timeout_seconds: parse_i32( + "SANDBOX_TIMEOUT_SECONDS", + raw.sandbox_timeout_seconds, + DEFAULT_SANDBOX_TIMEOUT_SECONDS, + )?, + }, payments: PaymentsConfig { support_url: raw .payments_support_url @@ -3266,6 +3295,8 @@ impl RawConfig { uploader_url: env("PLOTVA_UPLOADER_URL"), uploader_secret: env("PLOTVA_UPLOADER_SECRET"), uploader_timeout_seconds: env("PLOTVA_UPLOADER_TIMEOUT_SECONDS"), + sandbox_url: env("SANDBOX_URL"), + sandbox_timeout_seconds: env("SANDBOX_TIMEOUT_SECONDS"), payments_support_url: env("PAYMENTS_SUPPORT_URL"), whitecircle_enabled: env("WHITECIRCLE_ENABLED"), whitecircle_api_key: env("WHITECIRCLE_API_KEY"), diff --git a/crates/openplotva-dialog/src/dispatch.rs b/crates/openplotva-dialog/src/dispatch.rs index 124c6f13..6067892b 100644 --- a/crates/openplotva-dialog/src/dispatch.rs +++ b/crates/openplotva-dialog/src/dispatch.rs @@ -7,11 +7,12 @@ use openplotva_core::ChatMessageMeta; use crate::{ - DialogToolbox, DrawRequest, HistorySearchRequest, HistorySummaryRequest, RatesRequest, - STEP_CANCEL_DRAWING, STEP_CHAT_HISTORY_SUMMARY, STEP_CRAWL_URL, STEP_CURRENCY_RATES, - STEP_DRAW_IMAGE, STEP_GENERATE_SONG, STEP_HISTORY_SEARCH, STEP_QUEUE_STATUS, - STEP_TRANSLATE_TEXT, STEP_UNDERSTAND_MEDIA, STEP_WEB_SEARCH, STEP_YOUTUBE_SUMMARY, SongRequest, - ToolContext, ToolResult, ToolStep, ToolboxError, VisionRequest, + DialogToolbox, DrawRequest, HistorySearchRequest, HistorySummaryRequest, PythonRunRequest, + RatesRequest, STEP_CANCEL_DRAWING, STEP_CHAT_HISTORY_SUMMARY, STEP_CRAWL_URL, + STEP_CURRENCY_RATES, STEP_DRAW_IMAGE, STEP_GENERATE_SONG, STEP_HISTORY_SEARCH, + STEP_QUEUE_STATUS, STEP_RUN_PYTHON, STEP_TRANSLATE_TEXT, STEP_UNDERSTAND_MEDIA, + STEP_WEB_SEARCH, STEP_YOUTUBE_SUMMARY, SongRequest, ToolContext, ToolResult, ToolStep, + ToolboxError, VisionRequest, }; /// Dispatch one tool step to the toolbox. @@ -161,6 +162,33 @@ pub async fn dispatch_dialog_tool( }) .await } + STEP_RUN_PYTHON => { + if step.code.trim().is_empty() { + return Ok(ToolResult::failed( + "run_python_code_empty", + "run_python code is empty", + )); + } + toolbox + .run_python(PythonRunRequest { + context: meta.clone(), + code: step.code.clone(), + file_ids: step + .file_ids + .iter() + .map(|file_id| resolve_reference_id(file_id, meta)) + .filter(|file_id| !file_id.is_empty()) + .collect(), + send_files: step + .send_files + .iter() + .map(|path| path.trim().to_owned()) + .filter(|path| !path.is_empty()) + .collect(), + caption: step.caption.trim().to_owned(), + }) + .await + } other => Ok(ToolResult::failed( "unsupported_tool", format!("unsupported step {other:?}"), @@ -168,17 +196,19 @@ pub async fn dispatch_dialog_tool( } } +/// Map a dialog-scoped media handle to its stored reference. +fn resolve_reference_id(file_id: &str, meta: &ToolContext) -> String { + let normalized = file_id.trim().replace("\\_", "_"); + meta.image_reference_ids + .get(&normalized) + .cloned() + .unwrap_or(normalized) +} + /// Resolve an explicit handle, or the single media attachment on the trigger. fn vision_tool_file_id(file_id: &str, meta: &ToolContext) -> Option { - let file_id = file_id.trim(); - if !file_id.is_empty() { - let normalized = file_id.replace("\\_", "_"); - return Some( - meta.image_reference_ids - .get(&normalized) - .cloned() - .unwrap_or(normalized), - ); + if !file_id.trim().is_empty() { + return Some(resolve_reference_id(file_id, meta)); } single_current_media_file_id(&meta.message_meta) } diff --git a/crates/openplotva-dialog/src/lib.rs b/crates/openplotva-dialog/src/lib.rs index 2b82f569..0ea6f553 100644 --- a/crates/openplotva-dialog/src/lib.rs +++ b/crates/openplotva-dialog/src/lib.rs @@ -250,6 +250,24 @@ pub struct DrawRequest { pub seed: String, } +/// Run one Python script in the sandbox session of the current chat. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] +pub struct PythonRunRequest { + /// Tool context. + pub context: ToolContext, + /// Full Python source. + pub code: String, + /// Chat file references to copy into the session home before the run. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub file_ids: Vec, + /// Session-home paths to deliver to the chat after a successful run. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub send_files: Vec, + /// Caption for the delivered files. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub caption: String, +} + #[derive(Clone, Debug, Default, Deserialize, PartialEq, Serialize)] pub struct SongRequest { /// Tool context. @@ -337,6 +355,12 @@ pub type ToolboxFuture<'a> = Pin> + Send + 'a>>; pub trait DialogToolbox: Send + Sync { + /// Whether this toolbox can run the catalog tool `name`; sessions do not + /// advertise a tool whose backend is not configured. + fn offers_tool(&self, _name: &str) -> bool { + true + } + /// Context and memory tools share the same caller-bound execution boundary. fn agent_tool<'a>(&'a self, _context: ToolContext, _step: ToolStep) -> ToolboxFuture<'a> { unsupported_tool_future("agent_tool") @@ -401,6 +425,11 @@ pub trait DialogToolbox: Send + Sync { fn history_search<'a>(&'a self, _req: HistorySearchRequest) -> ToolboxFuture<'a> { unsupported_tool_future(STEP_HISTORY_SEARCH) } + + /// Python sandbox tool. + fn run_python<'a>(&'a self, _req: PythonRunRequest) -> ToolboxFuture<'a> { + unsupported_tool_future(STEP_RUN_PYTHON) + } } fn unsupported_tool_future<'a>(tool: &'static str) -> ToolboxFuture<'a> { @@ -421,6 +450,9 @@ pub const STEP_TRANSLATE_TEXT: &str = "translate_text"; pub const STEP_CHAT_HISTORY_SUMMARY: &str = "chat_history_summary"; pub const STEP_HISTORY_SEARCH: &str = "history_search"; pub const STEP_MEMORY_SEARCH: &str = "memory_search"; +pub const STEP_RUN_PYTHON: &str = "run_python"; +/// Most files one `run_python` call delivers to the chat. +pub const MAX_SEND_FILES: usize = 10; /// Session-engine tool: post an intermediate chat message without ending the /// turn. Never part of the shared catalog — the legacy provider loop must not @@ -446,6 +478,7 @@ const ALL_STEPS: &[&str] = &[ STEP_CHAT_HISTORY_SUMMARY, STEP_HISTORY_SEARCH, STEP_MEMORY_SEARCH, + STEP_RUN_PYTHON, "draw_api", "get_messages", "get_user_status", @@ -489,6 +522,8 @@ const INLINE_TOOL_ARG_KEYS: &[&str] = &[ "chat_id", "thread_id", "message_id", + "code", + "send_files", ]; const INLINE_ARG_END_MARKERS: &[&str] = &[ @@ -706,6 +741,29 @@ const CHAT_HISTORY_SUMMARY_ARGS: &[ToolArgSpec] = &[ }, ]; +const RUN_PYTHON_ARGS: &[ToolArgSpec] = &[ + ToolArgSpec { + name: "code", + required: true, + description: "Complete Python 3.14 script. The working directory is /home; save every output file there under a short Latin name, for example chart.png. Preinstalled: numpy, pandas, scipy, sympy, statsmodels, scikit-learn, networkx, shapely, matplotlib (Agg backend), seaborn, pillow, wordcloud, qrcode, graphviz, cairosvg, openpyxl, xlrd, python-docx, pypdf, fpdf2, lxml, beautifulsoup4, pyyaml, tabulate, ffmpeg-python with the ffmpeg binary. There is no network and no pip. Each call may use 256 MiB of memory, one CPU and 30 seconds. DejaVu Sans renders Cyrillic; its file is /usr/share/fonts/truetype/dejavu/DejaVuSans.ttf.", + }, + ToolArgSpec { + name: "file_ids", + required: false, + description: "Chat files to copy into /home before the run. Copy file_id or file_unique_id values from message attachments; the result lists the file names they get.", + }, + ToolArgSpec { + name: "send_files", + required: false, + description: "Paths in /home to send to the chat when the script succeeds, up to 10. Images are sent as photos, .gif as an animation, .mp4 as a video, .mp3 and .m4a as audio, other files as documents.", + }, + ToolArgSpec { + name: "caption", + required: false, + description: "Optional short caption for the sent files, in the user's language.", + }, +]; + const ALTERNATIVE_DIALOG_TOOL_CATALOG: &[ToolSpec] = &[ ToolSpec { name: "draw_api", @@ -906,6 +964,14 @@ const ALTERNATIVE_DIALOG_TOOL_CATALOG: &[ToolSpec] = &[ continuation: ToolContinuation::RequiresFollowup, args: CHAT_HISTORY_SUMMARY_ARGS, }, + ToolSpec { + name: STEP_RUN_PYTHON, + summary: "Run a Python script in a disposable sandbox without network access and send the files it creates to the chat.", + when_to_use: "Use for charts, plots, diagrams, floor plans, tables, file conversion, audio and video processing, data analysis and exact calculations, including data from chat files or from web_search and crawl_url results. To change an earlier chart or file, take the code of that earlier run_python call from the history and change only what the user asked for.", + result: "Returns the exit status, stdout, stderr and the files in /home. Files stay in /home for 60 seconds after the last call and at most 5 minutes, so the next call can use them. Files in send_files are delivered to the chat only when the script succeeds; after a delivery, reply briefly and do not describe the file again. On an error, fix the code and run it again.", + continuation: ToolContinuation::RequiresFollowup, + args: RUN_PYTHON_ARGS, + }, ]; #[must_use] @@ -1237,6 +1303,14 @@ fn tool_argument_schema(arg: &ToolArgSpec) -> Value { Value::from(openplotva_core::MAX_IMAGE_EDIT_SOURCE_IMAGES), ); } + "send_files" => { + schema.insert("type".to_owned(), Value::String("array".to_owned())); + schema.insert( + "items".to_owned(), + json!({"type": "string", "minLength": 1}), + ); + schema.insert("maxItems".to_owned(), Value::from(MAX_SEND_FILES)); + } "hours" => { schema.insert("type".to_owned(), Value::String("integer".to_owned())); schema.insert("minimum".to_owned(), Value::from(1)); @@ -2508,6 +2582,12 @@ pub struct ToolStep { /// Image seed. #[serde(default, skip_serializing_if = "String::is_empty")] pub seed: String, + /// Python source (`run_python`). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub code: String, + /// Session-home paths to deliver (`run_python`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub send_files: Vec, /// Reaction emoji (`react_to_message`). #[serde(default, skip_serializing_if = "String::is_empty")] pub emoji: String, @@ -5978,6 +6058,7 @@ mod tests { STEP_CANCEL_DRAWING, STEP_TRANSLATE_TEXT, STEP_CHAT_HISTORY_SUMMARY, + STEP_RUN_PYTHON, ] ); diff --git a/crates/openplotva-llm/src/aifarm.rs b/crates/openplotva-llm/src/aifarm.rs index 448bfe08..54b202f0 100644 --- a/crates/openplotva-llm/src/aifarm.rs +++ b/crates/openplotva-llm/src/aifarm.rs @@ -6676,6 +6676,17 @@ fn format_stored_tool_response_message( Value::String(tool_call.r#ref.trim().to_owned()), ); } + // The script is the artifact the model edits on a follow-up request. + if name == openplotva_dialog::STEP_RUN_PYTHON + && let Some(code) = tool_call + .input + .as_ref() + .and_then(|input| input.get("code")) + .and_then(Value::as_str) + .filter(|code| !code.trim().is_empty()) + { + payload.insert("code".to_owned(), Value::String(code.to_owned())); + } if let Some(output) = tool_call.output.clone() { payload.insert("output".to_owned(), output); } @@ -10914,6 +10925,34 @@ mod tests { Ok(()) } + #[test] + fn stored_run_python_results_carry_the_script_for_follow_up_edits() + -> Result<(), AifarmMessageError> { + let response = |name: &str| HistoryMessage { + role: ROLE_TOOL.to_owned(), + kind: MESSAGE_KIND_TOOL_RESPONSE.to_owned(), + message_id: 9, + tool_call: Some(ToolCall { + name: name.to_owned(), + r#ref: "call-1".to_owned(), + input: Some( + json!({"step": name, "code": "plt.bar([1], [2])\nplt.savefig('a.png')"}), + ), + output: Some(json!({"status": "ok", "data": {"files": ["a.png"]}})), + ..ToolCall::default() + }), + ..HistoryMessage::default() + }; + + let rendered = format_stored_tool_response_message(&response("run_python"))?; + assert!(rendered.contains(r#""code":"plt.bar([1], [2])\nplt.savefig('a.png')""#)); + assert!(rendered.find("\"code\"") < rendered.find("\"output\"")); + + let other = format_stored_tool_response_message(&response("web_search"))?; + assert!(!other.contains("plt.bar")); + Ok(()) + } + #[test] fn initial_messages_skip_protocol_only_assistant_history() -> Result<(), AifarmMessageError> { let mut input = base_input(); diff --git a/crates/openplotva-sandbox/Cargo.toml b/crates/openplotva-sandbox/Cargo.toml new file mode 100644 index 00000000..f0560dd1 --- /dev/null +++ b/crates/openplotva-sandbox/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "openplotva-sandbox" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[[bin]] +name = "openplotva-sandbox" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +axum.workspace = true +base64.workspace = true +reqwest.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true +tokio = { workspace = true, features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] } +tracing.workspace = true +tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } +url.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } + +[lints] +workspace = true diff --git a/crates/openplotva-sandbox/src/api.rs b/crates/openplotva-sandbox/src/api.rs new file mode 100644 index 00000000..c980e1e5 --- /dev/null +++ b/crates/openplotva-sandbox/src/api.rs @@ -0,0 +1,186 @@ +//! Wire types shared by the sandbox supervisor and its HTTP client. + +use serde::{Deserialize, Serialize}; + +/// Longest accepted session key. +pub const MAX_SESSION_KEY_LEN: usize = 64; +/// Longest accepted path of a file inside a session home. +pub const MAX_FILE_PATH_LEN: usize = 255; + +/// One file written into the session home before the code runs. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct InputFile { + /// Path relative to the session home. + pub path: String, + /// File content, standard base64. + pub content_base64: String, +} + +/// Run one Python script inside a session. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct RunRequest { + /// Full Python source. + pub code: String, + /// Files to write into the session home before the run. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub files: Vec, +} + +/// How the script ended. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Termination { + /// The interpreter exited on its own; see `exit_code`. + #[default] + Exited, + /// The per-call wall-clock limit killed the script. + Timeout, + /// A signal killed the script; with no timeout this is usually the memory limit. + Killed, + /// The sandbox could not start the script. + SandboxError, +} + +/// A regular file in the session home after the run. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct FileEntry { + /// Path relative to the session home. + pub path: String, + /// Size in bytes. + pub size: u64, + /// True when this run created or modified the file. + pub changed: bool, +} + +/// Session state after the call. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct SessionInfo { + /// True when this call started a new, empty session. + pub created: bool, + /// Seconds until the session closes if no call arrives. + pub idle_expires_in_seconds: u64, + /// Seconds until the session closes regardless of activity. + pub expires_in_seconds: u64, +} + +/// Result of one run. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct RunResponse { + pub termination: Termination, + /// Process exit code when the interpreter exited. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + pub stdout: String, + #[serde(default)] + pub stdout_truncated: bool, + pub stderr: String, + #[serde(default)] + pub stderr_truncated: bool, + pub duration_ms: u64, + /// Files in the session home, changed ones first. + #[serde(default)] + pub files: Vec, + pub session: SessionInfo, +} + +/// Error body of every non-2xx response. +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct ErrorBody { + /// Stable machine code, see the `ERROR_*` constants. + pub code: String, + pub message: String, +} + +/// Every session slot is taken; retry later. +pub const ERROR_BUSY: &str = "busy"; +/// The request is malformed or violates a limit. +pub const ERROR_INVALID_REQUEST: &str = "invalid_request"; +/// The session does not exist or has expired. +pub const ERROR_SESSION_NOT_FOUND: &str = "session_not_found"; +/// The file does not exist in the session home. +pub const ERROR_FILE_NOT_FOUND: &str = "file_not_found"; +/// The file exceeds the download limit. +pub const ERROR_FILE_TOO_LARGE: &str = "file_too_large"; +/// The supervisor failed. +pub const ERROR_INTERNAL: &str = "internal"; + +/// Whether `key` is a valid session key: 1–64 ASCII letters, digits, `-` or `_`. +#[must_use] +pub fn is_valid_session_key(key: &str) -> bool { + !key.is_empty() + && key.len() <= MAX_SESSION_KEY_LEN + && key + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') +} + +/// Split a relative file path into its components, rejecting anything that +/// could leave the session home or name a hidden file. +#[must_use] +pub fn relative_path_components(path: &str) -> Option> { + if path.is_empty() || path.len() > MAX_FILE_PATH_LEN || path.contains('\0') { + return None; + } + if path.starts_with('/') || path.contains('\\') { + return None; + } + let components = path.split('/').collect::>(); + components + .iter() + .all(|component| !component.is_empty() && !component.starts_with('.')) + .then_some(components) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_keys_allow_only_safe_ascii() { + assert!(is_valid_session_key("chat-100123_t0")); + assert!(!is_valid_session_key("")); + assert!(!is_valid_session_key("chat/1")); + assert!(!is_valid_session_key("чат")); + assert!(!is_valid_session_key(&"a".repeat(MAX_SESSION_KEY_LEN + 1))); + } + + #[test] + fn relative_paths_reject_escapes_and_hidden_names() { + assert_eq!( + relative_path_components("out/chart.png"), + Some(vec!["out", "chart.png"]) + ); + assert_eq!( + relative_path_components("данные.csv"), + Some(vec!["данные.csv"]) + ); + for bad in [ + "", + "/etc/passwd", + "../x", + "a/../b", + "./a", + ".config/x", + "a//b", + "a/", + "a\\b", + "a\0b", + ] { + assert_eq!(relative_path_components(bad), None, "{bad:?}"); + } + } + + #[test] + fn run_response_round_trips_with_snake_case_termination() { + let response = RunResponse { + termination: Termination::Timeout, + stdout: "ok".to_owned(), + ..RunResponse::default() + }; + let json = serde_json::to_value(&response).expect("serialize"); + assert_eq!(json["termination"], "timeout"); + assert!(json.get("exit_code").is_none()); + let back: RunResponse = serde_json::from_value(json).expect("deserialize"); + assert_eq!(back, response); + } +} diff --git a/crates/openplotva-sandbox/src/client.rs b/crates/openplotva-sandbox/src/client.rs new file mode 100644 index 00000000..39d093ce --- /dev/null +++ b/crates/openplotva-sandbox/src/client.rs @@ -0,0 +1,175 @@ +//! HTTP client the bot uses to reach the sandbox supervisor. + +use std::time::Duration; + +use reqwest::StatusCode; +use thiserror::Error; +use url::Url; + +use crate::api::{ + ERROR_BUSY, ERROR_FILE_NOT_FOUND, ERROR_SESSION_NOT_FOUND, ErrorBody, RunRequest, RunResponse, +}; + +/// Error returned by [`SandboxClient`]. +#[derive(Debug, Error)] +pub enum SandboxClientError { + /// The configured base URL is not usable. + #[error("invalid sandbox url: {0}")] + InvalidUrl(String), + /// Every session slot is taken. + #[error("sandbox is busy: {0}")] + Busy(String), + /// The session or file does not exist. + #[error("{code}: {message}")] + NotFound { code: String, message: String }, + /// The supervisor rejected the request. + #[error("{code}: {message}")] + Rejected { code: String, message: String }, + /// The request did not reach the supervisor or the response was cut. + #[error("sandbox transport: {0}")] + Transport(String), + /// The response body is not the expected JSON. + #[error("sandbox response: {0}")] + Decode(String), +} + +/// Client for one supervisor base URL. +#[derive(Clone, Debug)] +pub struct SandboxClient { + http: reqwest::Client, + base: Url, +} + +impl SandboxClient { + /// Build a client for `base_url`, for example `http://sandbox:8090`. + pub fn new(base_url: &str, timeout: Duration) -> Result { + let mut base = Url::parse(base_url.trim()) + .map_err(|error| SandboxClientError::InvalidUrl(error.to_string()))?; + if base.cannot_be_a_base() { + return Err(SandboxClientError::InvalidUrl(base_url.to_owned())); + } + if !base.path().ends_with('/') { + let path = format!("{}/", base.path()); + base.set_path(&path); + } + let http = reqwest::Client::builder() + .timeout(timeout) + .build() + .map_err(|error| SandboxClientError::Transport(error.to_string()))?; + Ok(Self { http, base }) + } + + /// Run a script in the session, creating the session when needed. + pub async fn run( + &self, + session: &str, + request: &RunRequest, + ) -> Result { + let url = self.endpoint(&["v1", "sessions", session, "run"])?; + let response = self + .http + .post(url) + .json(request) + .send() + .await + .map_err(|error| SandboxClientError::Transport(error.to_string()))?; + let response = error_for_status(response).await?; + response + .json::() + .await + .map_err(|error| SandboxClientError::Decode(error.to_string())) + } + + /// Read one file from the session home. + pub async fn read_file( + &self, + session: &str, + path: &str, + ) -> Result, SandboxClientError> { + let mut segments = vec!["v1", "sessions", session, "files"]; + segments.extend(path.split('/')); + let url = self.endpoint(&segments)?; + let response = self + .http + .get(url) + .send() + .await + .map_err(|error| SandboxClientError::Transport(error.to_string()))?; + let response = error_for_status(response).await?; + response + .bytes() + .await + .map(|bytes| bytes.to_vec()) + .map_err(|error| SandboxClientError::Transport(error.to_string())) + } + + fn endpoint(&self, segments: &[&str]) -> Result { + let mut url = self.base.clone(); + url.path_segments_mut() + .map_err(|()| SandboxClientError::InvalidUrl(self.base.to_string()))? + .pop_if_empty() + .extend(segments); + Ok(url) + } +} + +async fn error_for_status( + response: reqwest::Response, +) -> Result { + let status = response.status(); + if status.is_success() { + return Ok(response); + } + let text = response.text().await.unwrap_or_default(); + let body = serde_json::from_str::(&text).unwrap_or_else(|_| ErrorBody { + code: status.as_u16().to_string(), + message: text.chars().take(300).collect(), + }); + Err(match (status, body.code.as_str()) { + (_, ERROR_BUSY) | (StatusCode::SERVICE_UNAVAILABLE, _) => { + SandboxClientError::Busy(body.message) + } + (_, ERROR_SESSION_NOT_FOUND | ERROR_FILE_NOT_FOUND) => SandboxClientError::NotFound { + code: body.code, + message: body.message, + }, + _ => SandboxClientError::Rejected { + code: body.code, + message: body.message, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoints_percent_encode_each_path_segment() { + let client = + SandboxClient::new("http://sandbox:8090", Duration::from_secs(5)).expect("client"); + let url = client + .endpoint(&["v1", "sessions", "chat-1", "files", "графики", "a b.png"]) + .expect("url"); + assert_eq!( + url.as_str(), + "http://sandbox:8090/v1/sessions/chat-1/files/%D0%B3%D1%80%D0%B0%D1%84%D0%B8%D0%BA%D0%B8/a%20b.png" + ); + } + + #[test] + fn base_path_prefix_is_kept() { + let client = + SandboxClient::new("http://proxy/sandbox", Duration::from_secs(5)).expect("client"); + let url = client + .endpoint(&["v1", "sessions", "k", "run"]) + .expect("url"); + assert_eq!(url.as_str(), "http://proxy/sandbox/v1/sessions/k/run"); + } + + #[test] + fn rejects_unusable_base_urls() { + assert!(SandboxClient::new("not a url", Duration::from_secs(5)).is_err()); + assert!(SandboxClient::new("mailto:x@example.com", Duration::from_secs(5)).is_err()); + } +} diff --git a/crates/openplotva-sandbox/src/lib.rs b/crates/openplotva-sandbox/src/lib.rs new file mode 100644 index 00000000..0fa0765f --- /dev/null +++ b/crates/openplotva-sandbox/src/lib.rs @@ -0,0 +1,6 @@ +//! Python sandbox for dialog tools: wire types, the HTTP client the bot uses, +//! and the supervisor that runs scripts under nsjail in disposable sessions. + +pub mod api; +pub mod client; +pub mod server; diff --git a/crates/openplotva-sandbox/src/main.rs b/crates/openplotva-sandbox/src/main.rs new file mode 100644 index 00000000..92d01fe7 --- /dev/null +++ b/crates/openplotva-sandbox/src/main.rs @@ -0,0 +1,14 @@ +use openplotva_sandbox::server::{SupervisorConfig, serve}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + ) + .with_target(false) + .with_ansi(false) + .init(); + serve(SupervisorConfig::from_env()?).await +} diff --git a/crates/openplotva-sandbox/src/server/config.rs b/crates/openplotva-sandbox/src/server/config.rs new file mode 100644 index 00000000..a9ee977c --- /dev/null +++ b/crates/openplotva-sandbox/src/server/config.rs @@ -0,0 +1,176 @@ +use std::net::SocketAddr; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::{Context, bail}; + +/// Supervisor settings, read from `SANDBOX_*` environment variables. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SupervisorConfig { + /// `SANDBOX_BIND`. + pub bind: SocketAddr, + /// `SANDBOX_NSJAIL`. + pub nsjail_path: PathBuf, + /// `SANDBOX_NSJAIL_CONFIG`. + pub nsjail_config: PathBuf, + /// `SANDBOX_PYTHON`. + pub python: PathBuf, + /// `SANDBOX_SESSIONS_DIR`: session homes and per-call scratch files. + pub sessions_dir: PathBuf, + /// `SANDBOX_MATPLOTLIB_CACHE`: prebuilt font list copied into each home. + pub matplotlib_cache: Option, + /// `SANDBOX_MAX_SESSIONS`. + pub max_sessions: usize, + /// `SANDBOX_IDLE_SECONDS`: a session closes this long after its last call. + pub idle_ttl: Duration, + /// `SANDBOX_MAX_SESSION_SECONDS`: a session closes this long after it opened. + pub max_age: Duration, + /// `SANDBOX_HOME_MIB`: tmpfs size of one session home. + pub home_size_mib: u64, + /// `SANDBOX_SLOT_WAIT_SECONDS`: how long a new session waits for a free slot. + pub slot_wait: Duration, + /// `SANDBOX_CALL_TIMEOUT_SECONDS`: supervisor backstop above the nsjail time limit. + pub call_timeout: Duration, + /// Bytes kept from each of stdout and stderr. + pub max_output_bytes: usize, + /// Entries returned in a file listing. + pub max_listed_files: usize, + /// Largest file the supervisor serves. + pub max_read_bytes: u64, + /// Largest accepted request body. + pub max_request_bytes: usize, +} + +impl Default for SupervisorConfig { + fn default() -> Self { + Self { + bind: SocketAddr::from(([0, 0, 0, 0], 8090)), + nsjail_path: PathBuf::from("/usr/sbin/nsjail"), + nsjail_config: PathBuf::from("/etc/sandbox/python.cfg"), + python: PathBuf::from("/opt/sandbox/venv/bin/python"), + sessions_dir: PathBuf::from("/sessions"), + matplotlib_cache: Some(PathBuf::from("/opt/sandbox/matplotlib")), + max_sessions: 4, + idle_ttl: Duration::from_secs(60), + max_age: Duration::from_secs(300), + home_size_mib: 100, + slot_wait: Duration::from_secs(10), + call_timeout: Duration::from_secs(45), + max_output_bytes: 256 * 1024, + max_listed_files: 100, + max_read_bytes: 50 * 1024 * 1024, + max_request_bytes: 96 * 1024 * 1024, + } + } +} + +impl SupervisorConfig { + /// Read the configuration from the process environment. + pub fn from_env() -> anyhow::Result { + Self::from_lookup(|key| std::env::var(key).ok()) + } + + fn from_lookup(lookup: impl Fn(&str) -> Option) -> anyhow::Result { + let defaults = Self::default(); + let value = |key: &str| lookup(key).filter(|value| !value.trim().is_empty()); + let seconds = |key: &str, default: Duration| -> anyhow::Result { + value(key).map_or(Ok(default), |raw| { + raw.trim() + .parse::() + .map(Duration::from_secs) + .with_context(|| format!("{key} must be whole seconds")) + }) + }; + let config = Self { + bind: value("SANDBOX_BIND").map_or(Ok(defaults.bind), |raw| { + raw.trim().parse().context("SANDBOX_BIND must be host:port") + })?, + nsjail_path: value("SANDBOX_NSJAIL").map_or(defaults.nsjail_path, PathBuf::from), + nsjail_config: value("SANDBOX_NSJAIL_CONFIG") + .map_or(defaults.nsjail_config, PathBuf::from), + python: value("SANDBOX_PYTHON").map_or(defaults.python, PathBuf::from), + sessions_dir: value("SANDBOX_SESSIONS_DIR") + .map_or(defaults.sessions_dir, PathBuf::from), + matplotlib_cache: match lookup("SANDBOX_MATPLOTLIB_CACHE") { + Some(raw) if raw.trim().is_empty() => None, + Some(raw) => Some(PathBuf::from(raw.trim())), + None => defaults.matplotlib_cache, + }, + max_sessions: value("SANDBOX_MAX_SESSIONS").map_or( + Ok(defaults.max_sessions), + |raw| { + raw.trim() + .parse() + .context("SANDBOX_MAX_SESSIONS must be a positive integer") + }, + )?, + idle_ttl: seconds("SANDBOX_IDLE_SECONDS", defaults.idle_ttl)?, + max_age: seconds("SANDBOX_MAX_SESSION_SECONDS", defaults.max_age)?, + home_size_mib: value("SANDBOX_HOME_MIB").map_or(Ok(defaults.home_size_mib), |raw| { + raw.trim() + .parse() + .context("SANDBOX_HOME_MIB must be a positive integer") + })?, + slot_wait: seconds("SANDBOX_SLOT_WAIT_SECONDS", defaults.slot_wait)?, + call_timeout: seconds("SANDBOX_CALL_TIMEOUT_SECONDS", defaults.call_timeout)?, + ..defaults + }; + if config.max_sessions == 0 || config.home_size_mib == 0 { + bail!("SANDBOX_MAX_SESSIONS and SANDBOX_HOME_MIB must be positive"); + } + if config.idle_ttl.is_zero() || config.max_age < config.idle_ttl { + bail!("SANDBOX_MAX_SESSION_SECONDS must be at least SANDBOX_IDLE_SECONDS"); + } + Ok(config) + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + fn config(vars: &[(&str, &str)]) -> anyhow::Result { + let vars = vars + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect::>(); + SupervisorConfig::from_lookup(|key| vars.get(key).cloned()) + } + + #[test] + fn defaults_match_the_agreed_limits() { + let config = config(&[]).expect("defaults"); + assert_eq!(config.max_sessions, 4); + assert_eq!(config.idle_ttl, Duration::from_secs(60)); + assert_eq!(config.max_age, Duration::from_secs(300)); + assert_eq!(config.home_size_mib, 100); + } + + #[test] + fn overrides_and_rejects_inconsistent_values() { + let config = config(&[ + ("SANDBOX_BIND", "127.0.0.1:9000"), + ("SANDBOX_MAX_SESSIONS", "2"), + ("SANDBOX_MATPLOTLIB_CACHE", ""), + ]) + .expect("overrides"); + assert_eq!(config.bind, SocketAddr::from(([127, 0, 0, 1], 9000))); + assert_eq!(config.max_sessions, 2); + assert_eq!(config.matplotlib_cache, None); + + assert!( + super::SupervisorConfig::from_lookup(|key| { + (key == "SANDBOX_MAX_SESSIONS").then(|| "0".to_owned()) + }) + .is_err() + ); + assert!( + super::SupervisorConfig::from_lookup(|key| { + (key == "SANDBOX_MAX_SESSION_SECONDS").then(|| "10".to_owned()) + }) + .is_err() + ); + } +} diff --git a/crates/openplotva-sandbox/src/server/home.rs b/crates/openplotva-sandbox/src/server/home.rs new file mode 100644 index 00000000..b9bbb2d1 --- /dev/null +++ b/crates/openplotva-sandbox/src/server/home.rs @@ -0,0 +1,372 @@ +//! Session home directories and the file operations the supervisor performs in them. +//! +//! Sandboxed code controls the home tree, so every path is walked without +//! following symlinks. Callers hold the session run lock, so no jailed process +//! can change the tree during these operations. + +use std::collections::BTreeMap; +use std::fs; +use std::io; +use std::os::unix::fs::chown; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use thiserror::Error; +use tokio::process::Command; + +use crate::api::{FileEntry, relative_path_components}; + +/// UID and GID the jailed process runs as. +pub const SANDBOX_UID: u32 = 65534; +const MAX_WALK_DEPTH: usize = 16; +const MAX_WALK_ENTRIES: usize = 5000; +const TMPFS_INODES: u32 = 4096; + +/// How session homes are provisioned. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum HomeStorage { + /// A dedicated tmpfs per session, owned by the sandbox user. + Tmpfs { size_mib: u64 }, + /// A plain directory, for tests that run without privileges. + PlainDir, +} + +impl HomeStorage { + fn owner(&self) -> Option { + match self { + Self::Tmpfs { .. } => Some(SANDBOX_UID), + Self::PlainDir => None, + } + } + + /// Create an empty home at `dir` and seed it with the matplotlib font cache. + pub async fn create(&self, dir: &Path, matplotlib_cache: Option<&Path>) -> io::Result<()> { + tokio::fs::create_dir_all(dir).await?; + if let Self::Tmpfs { size_mib } = self { + let options = format!( + "size={size_mib}m,nr_inodes={TMPFS_INODES},mode=0755,uid={SANDBOX_UID},gid={SANDBOX_UID},nosuid,nodev" + ); + let status = Command::new("mount") + .args(["-t", "tmpfs", "-o", &options, "tmpfs"]) + .arg(dir) + .status() + .await?; + if !status.success() { + return Err(io::Error::other(format!( + "mount tmpfs exited with {status}" + ))); + } + } + if let Some(cache) = matplotlib_cache.filter(|cache| cache.is_dir()) { + let owner = self.owner(); + let cache = cache.to_path_buf(); + let target = dir.join(".config").join("matplotlib"); + tokio::task::spawn_blocking(move || copy_tree(&cache, &target, owner)) + .await + .map_err(io::Error::other)??; + } + Ok(()) + } + + /// Remove a home created by [`HomeStorage::create`]. + pub async fn destroy(&self, dir: &Path) { + if matches!(self, Self::Tmpfs { .. }) { + match Command::new("umount").arg("-l").arg(dir).status().await { + Ok(status) if status.success() => {} + Ok(status) => tracing::warn!(dir = %dir.display(), %status, "umount failed"), + Err(error) => tracing::warn!(dir = %dir.display(), %error, "umount failed"), + } + } + if let Err(error) = tokio::fs::remove_dir_all(dir).await + && error.kind() != io::ErrorKind::NotFound + { + tracing::warn!(dir = %dir.display(), %error, "failed to remove session home"); + } + } + + /// Write one input file, creating parent directories inside the home. + pub fn write_file(&self, home: &Path, path: &str, content: &[u8]) -> Result<(), HomeFileError> { + let components = relative_path_components(path).ok_or(HomeFileError::InvalidPath)?; + let (name, parents) = components.split_last().ok_or(HomeFileError::InvalidPath)?; + let mut current = home.to_path_buf(); + for component in parents { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => return Err(HomeFileError::NotADirectory), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::create_dir(¤t)?; + self.set_owner(¤t)?; + } + Err(error) => return Err(error.into()), + } + } + current.push(name); + match fs::symlink_metadata(¤t) { + Ok(metadata) if !metadata.is_file() => return Err(HomeFileError::NotAFile), + Ok(_) => {} + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + fs::write(¤t, content)?; + self.set_owner(¤t) + } + + fn set_owner(&self, path: &Path) -> Result<(), HomeFileError> { + if let Some(uid) = self.owner() { + chown(path, Some(uid), Some(uid))?; + } + Ok(()) + } +} + +/// Error from a file operation inside a session home. +#[derive(Debug, Error)] +pub enum HomeFileError { + #[error("path must be relative, without '..' or hidden components")] + InvalidPath, + #[error("a path component is not a directory")] + NotADirectory, + #[error("the path is not a regular file")] + NotAFile, + #[error("file not found")] + NotFound, + #[error("file is {size} bytes, more than the {limit}-byte limit")] + TooLarge { size: u64, limit: u64 }, + #[error(transparent)] + Io(#[from] io::Error), +} + +/// Read one regular file without following symlinks. +pub fn read_file(home: &Path, path: &str, limit: u64) -> Result, HomeFileError> { + let components = relative_path_components(path).ok_or(HomeFileError::InvalidPath)?; + let (name, parents) = components.split_last().ok_or(HomeFileError::InvalidPath)?; + let mut current = home.to_path_buf(); + for component in parents { + current.push(component); + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.is_dir() => {} + Ok(_) => return Err(HomeFileError::NotFound), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Err(HomeFileError::NotFound); + } + Err(error) => return Err(error.into()), + } + } + current.push(name); + let metadata = match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.is_file() => metadata, + Ok(_) => return Err(HomeFileError::NotFound), + Err(error) if error.kind() == io::ErrorKind::NotFound => { + return Err(HomeFileError::NotFound); + } + Err(error) => return Err(error.into()), + }; + if metadata.len() > limit { + return Err(HomeFileError::TooLarge { + size: metadata.len(), + limit, + }); + } + Ok(fs::read(¤t)?) +} + +/// Size and modification time of every visible regular file, keyed by relative path. +pub type HomeSnapshot = BTreeMap)>; + +/// Walk the visible part of a home: hidden names and symlinks are skipped. +#[must_use] +pub fn snapshot(home: &Path) -> HomeSnapshot { + let mut files = HomeSnapshot::new(); + let mut stack = vec![(home.to_path_buf(), String::new(), 0_usize)]; + let mut visited = 0_usize; + while let Some((dir, prefix, depth)) = stack.pop() { + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + visited += 1; + if visited > MAX_WALK_ENTRIES { + return files; + } + let Some(name) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if name.starts_with('.') { + continue; + } + let Ok(metadata) = fs::symlink_metadata(entry.path()) else { + continue; + }; + let relative = if prefix.is_empty() { + name + } else { + format!("{prefix}/{name}") + }; + if metadata.is_dir() && depth < MAX_WALK_DEPTH { + stack.push((entry.path(), relative, depth + 1)); + } else if metadata.is_file() { + files.insert(relative, (metadata.len(), metadata.modified().ok())); + } + } + } + files +} + +/// List files after a run: changed files first, then the rest, each group by path. +#[must_use] +pub fn file_entries(before: &HomeSnapshot, after: &HomeSnapshot, limit: usize) -> Vec { + let (mut changed, mut unchanged): (Vec<_>, Vec<_>) = after + .iter() + .map(|(path, state)| FileEntry { + path: path.clone(), + size: state.0, + changed: before.get(path) != Some(state), + }) + .partition(|entry| entry.changed); + changed.append(&mut unchanged); + changed.truncate(limit); + changed +} + +fn copy_tree(source: &Path, target: &Path, owner: Option) -> io::Result<()> { + let mut created = Vec::::new(); + let mut stack = vec![(source.to_path_buf(), target.to_path_buf())]; + while let Some((from, to)) = stack.pop() { + fs::create_dir_all(&to)?; + created.push(to.clone()); + for entry in fs::read_dir(&from)? { + let entry = entry?; + let file_type = entry.file_type()?; + let destination = to.join(entry.file_name()); + if file_type.is_dir() { + stack.push((entry.path(), destination)); + } else if file_type.is_file() { + fs::copy(entry.path(), &destination)?; + created.push(destination); + } + } + } + if let Some(uid) = owner { + if let Some(parent) = target.parent() { + chown(parent, Some(uid), Some(uid))?; + } + for path in created { + chown(&path, Some(uid), Some(uid))?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::symlink; + + use super::*; + + fn temp_home(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "openplotva-sandbox-home-{name}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).expect("create temp home"); + dir + } + + #[test] + fn writes_nested_files_and_reads_them_back() { + let home = temp_home("write"); + let storage = HomeStorage::PlainDir; + storage + .write_file(&home, "data/табл.csv", b"a,b\n1,2\n") + .expect("write"); + assert_eq!( + read_file(&home, "data/табл.csv", 1024).expect("read"), + b"a,b\n1,2\n" + ); + assert!(matches!( + read_file(&home, "data/табл.csv", 3), + Err(HomeFileError::TooLarge { size: 8, limit: 3 }) + )); + fs::remove_dir_all(home).expect("cleanup"); + } + + #[test] + fn never_follows_symlinks_planted_by_sandboxed_code() { + let home = temp_home("symlink"); + let outside = temp_home("outside"); + fs::write(outside.join("secret"), b"secret").expect("secret"); + symlink(outside.join("secret"), home.join("leak.txt")).expect("file link"); + symlink(&outside, home.join("dir")).expect("dir link"); + + assert!(matches!( + read_file(&home, "leak.txt", 1024), + Err(HomeFileError::NotFound) + )); + assert!(matches!( + read_file(&home, "dir/secret", 1024), + Err(HomeFileError::NotFound) + )); + let storage = HomeStorage::PlainDir; + assert!(matches!( + storage.write_file(&home, "leak.txt", b"x"), + Err(HomeFileError::NotAFile) + )); + assert!(matches!( + storage.write_file(&home, "dir/new.txt", b"x"), + Err(HomeFileError::NotADirectory) + )); + assert_eq!(fs::read(outside.join("secret")).expect("intact"), b"secret"); + assert!(!snapshot(&home).contains_key("leak.txt")); + + fs::remove_dir_all(home).expect("cleanup"); + fs::remove_dir_all(outside).expect("cleanup"); + } + + #[test] + fn listing_puts_changed_files_first_and_hides_dotfiles() { + let home = temp_home("listing"); + fs::write(home.join("old.txt"), b"old").expect("old"); + fs::create_dir_all(home.join(".config")).expect("hidden"); + fs::write(home.join(".config").join("x"), b"x").expect("hidden file"); + let before = snapshot(&home); + fs::create_dir_all(home.join("out")).expect("out"); + fs::write(home.join("out").join("chart.png"), b"png").expect("chart"); + + let entries = file_entries(&before, &snapshot(&home), 10); + assert_eq!( + entries, + vec![ + FileEntry { + path: "out/chart.png".to_owned(), + size: 3, + changed: true, + }, + FileEntry { + path: "old.txt".to_owned(), + size: 3, + changed: false, + }, + ] + ); + assert_eq!(file_entries(&before, &snapshot(&home), 1).len(), 1); + fs::remove_dir_all(home).expect("cleanup"); + } + + #[tokio::test] + async fn plain_homes_get_the_matplotlib_cache() { + let root = temp_home("seed"); + let cache = root.join("cache"); + fs::create_dir_all(&cache).expect("cache"); + fs::write(cache.join("fontlist-v390.json"), b"{}").expect("fontlist"); + let home = root.join("home"); + let storage = HomeStorage::PlainDir; + storage.create(&home, Some(&cache)).await.expect("create"); + assert!(home.join(".config/matplotlib/fontlist-v390.json").is_file()); + assert!(snapshot(&home).is_empty()); + storage.destroy(&home).await; + assert!(!home.exists()); + fs::remove_dir_all(root).expect("cleanup"); + } +} diff --git a/crates/openplotva-sandbox/src/server/http.rs b/crates/openplotva-sandbox/src/server/http.rs new file mode 100644 index 00000000..6e6c6c75 --- /dev/null +++ b/crates/openplotva-sandbox/src/server/http.rs @@ -0,0 +1,399 @@ +//! HTTP routes of the supervisor. + +use std::sync::Arc; + +use axum::Json; +use axum::Router; +use axum::body::Body; +use axum::extract::rejection::JsonRejection; +use axum::extract::{DefaultBodyLimit, Path, State}; +use axum::http::{StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{delete, get, post}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use serde_json::json; + +use super::home::{self, HomeFileError, HomeStorage}; +use super::runner::CodeRunner; +use super::session::{SessionError, SessionManager}; +use crate::api::{ + ERROR_BUSY, ERROR_FILE_NOT_FOUND, ERROR_FILE_TOO_LARGE, ERROR_INTERNAL, ERROR_INVALID_REQUEST, + ERROR_SESSION_NOT_FOUND, ErrorBody, RunRequest, RunResponse, is_valid_session_key, +}; + +/// Longest accepted script. +pub const MAX_CODE_BYTES: usize = 512 * 1024; + +/// Shared state of the routes. +pub struct AppState { + pub sessions: Arc, + pub runner: Arc, + pub max_listed_files: usize, + pub max_read_bytes: u64, + pub max_input_bytes: u64, +} + +/// Build the supervisor router. +pub fn router(state: Arc, max_request_bytes: usize) -> Router { + Router::new() + .route("/health", get(health)) + .route("/v1/sessions/{key}/run", post(run)) + .route("/v1/sessions/{key}/files/{*path}", get(read_file)) + .route("/v1/sessions/{key}", delete(close)) + .layer(DefaultBodyLimit::max(max_request_bytes)) + .with_state(state) +} + +async fn health(State(state): State>) -> Json { + Json(json!({ "status": "ok", "sessions": state.sessions.live() })) +} + +async fn run( + State(state): State>, + Path(key): Path, + body: Result, JsonRejection>, +) -> Response { + if !is_valid_session_key(&key) { + return error( + StatusCode::BAD_REQUEST, + ERROR_INVALID_REQUEST, + "invalid session key", + ); + } + let request = match body { + Ok(Json(request)) => request, + Err(rejection) => { + return error( + StatusCode::BAD_REQUEST, + ERROR_INVALID_REQUEST, + &rejection.body_text(), + ); + } + }; + if request.code.trim().is_empty() || request.code.len() > MAX_CODE_BYTES { + return error( + StatusCode::BAD_REQUEST, + ERROR_INVALID_REQUEST, + &format!("code must be 1..{MAX_CODE_BYTES} bytes"), + ); + } + let mut inputs = Vec::with_capacity(request.files.len()); + let mut input_bytes = 0_u64; + for file in &request.files { + let Ok(content) = BASE64.decode(file.content_base64.as_bytes()) else { + return error( + StatusCode::BAD_REQUEST, + ERROR_INVALID_REQUEST, + &format!("{}: content is not base64", file.path), + ); + }; + input_bytes += content.len() as u64; + inputs.push((file.path.clone(), content)); + } + if input_bytes > state.max_input_bytes { + return error( + StatusCode::BAD_REQUEST, + ERROR_INVALID_REQUEST, + &format!("input files exceed {} bytes", state.max_input_bytes), + ); + } + + let lease = match state.sessions.lease(&key).await { + Ok(lease) => lease, + Err(SessionError::Busy(limit)) => { + return error( + StatusCode::SERVICE_UNAVAILABLE, + ERROR_BUSY, + &format!("all {limit} sandbox sessions are in use; retry later"), + ); + } + Err(SessionError::Home(io)) => { + tracing::error!(%io, "failed to open a sandbox session"); + return error( + StatusCode::INTERNAL_SERVER_ERROR, + ERROR_INTERNAL, + "session setup failed", + ); + } + }; + let home_dir = lease.home().to_path_buf(); + let storage = state.sessions.storage().clone(); + let before = match blocking(move || write_inputs(&storage, &home_dir, &inputs)).await { + Ok(Ok(before)) => before, + Ok(Err((path, file_error))) => { + return error( + StatusCode::BAD_REQUEST, + ERROR_INVALID_REQUEST, + &format!("{path}: {file_error}"), + ); + } + Err(message) => { + return error(StatusCode::INTERNAL_SERVER_ERROR, ERROR_INTERNAL, &message); + } + }; + + let outcome = state.runner.run(lease.home(), &request.code).await; + let home_dir = lease.home().to_path_buf(); + let after = blocking(move || home::snapshot(&home_dir)) + .await + .unwrap_or_default(); + let files = home::file_entries(&before, &after, state.max_listed_files); + let session = lease.finish(); + Json(RunResponse { + termination: outcome.termination, + exit_code: outcome.exit_code, + stdout: outcome.stdout, + stdout_truncated: outcome.stdout_truncated, + stderr: outcome.stderr, + stderr_truncated: outcome.stderr_truncated, + duration_ms: u64::try_from(outcome.duration.as_millis()).unwrap_or(u64::MAX), + files, + session, + }) + .into_response() +} + +async fn read_file( + State(state): State>, + Path((key, path)): Path<(String, String)>, +) -> Response { + if !is_valid_session_key(&key) { + return error( + StatusCode::BAD_REQUEST, + ERROR_INVALID_REQUEST, + "invalid session key", + ); + } + let Some(lease) = state.sessions.lease_existing(&key).await else { + return error( + StatusCode::NOT_FOUND, + ERROR_SESSION_NOT_FOUND, + "the sandbox session has expired", + ); + }; + let home_dir = lease.home().to_path_buf(); + let limit = state.max_read_bytes; + let requested = path.clone(); + let result = blocking(move || home::read_file(&home_dir, &requested, limit)).await; + let _session = lease.finish(); + match result { + Ok(Ok(bytes)) => ( + [(header::CONTENT_TYPE, "application/octet-stream")], + Body::from(bytes), + ) + .into_response(), + Ok(Err(HomeFileError::InvalidPath)) => error( + StatusCode::BAD_REQUEST, + ERROR_INVALID_REQUEST, + "invalid file path", + ), + Ok(Err(HomeFileError::TooLarge { size, limit })) => error( + StatusCode::PAYLOAD_TOO_LARGE, + ERROR_FILE_TOO_LARGE, + &format!("{path} is {size} bytes; the limit is {limit}"), + ), + Ok(Err(HomeFileError::Io(io))) => { + tracing::warn!(%io, "failed to read a session file"); + error( + StatusCode::INTERNAL_SERVER_ERROR, + ERROR_INTERNAL, + "file read failed", + ) + } + Ok(Err(_)) => error( + StatusCode::NOT_FOUND, + ERROR_FILE_NOT_FOUND, + &format!("{path} does not exist"), + ), + Err(message) => error(StatusCode::INTERNAL_SERVER_ERROR, ERROR_INTERNAL, &message), + } +} + +async fn close(State(state): State>, Path(key): Path) -> Response { + if state.sessions.close(&key).await { + StatusCode::NO_CONTENT.into_response() + } else { + error( + StatusCode::NOT_FOUND, + ERROR_SESSION_NOT_FOUND, + "no such session", + ) + } +} + +type WriteFailure = (String, HomeFileError); + +fn write_inputs( + storage: &HomeStorage, + home: &std::path::Path, + inputs: &[(String, Vec)], +) -> Result { + for (path, content) in inputs { + storage + .write_file(home, path, content) + .map_err(|file_error| (path.clone(), file_error))?; + } + Ok(home::snapshot(home)) +} + +async fn blocking( + work: impl FnOnce() -> T + Send + 'static, +) -> Result { + tokio::task::spawn_blocking(work) + .await + .map_err(|join| format!("blocking task failed: {join}")) +} + +fn error(status: StatusCode, code: &str, message: &str) -> Response { + ( + status, + Json(ErrorBody { + code: code.to_owned(), + message: message.to_owned(), + }), + ) + .into_response() +} + +#[cfg(test)] +mod tests { + use std::path::Path as FsPath; + use std::time::Duration; + + use super::*; + use crate::api::{FileEntry, InputFile, Termination}; + use crate::client::{SandboxClient, SandboxClientError}; + use crate::server::runner::{RunFuture, RunOutcome}; + use crate::server::session::SessionLimits; + + struct EchoRunner; + + impl CodeRunner for EchoRunner { + fn run<'a>(&'a self, home: &'a FsPath, code: &'a str) -> RunFuture<'a> { + Box::pin(async move { + let input = std::fs::read_to_string(home.join("in.txt")).unwrap_or_default(); + std::fs::write(home.join("out.txt"), format!("{code}|{input}")).expect("write"); + RunOutcome { + termination: Termination::Exited, + exit_code: Some(0), + stdout: "done\n".to_owned(), + ..RunOutcome::default() + } + }) + } + } + + async fn serve(name: &str) -> (SandboxClient, std::path::PathBuf) { + let root = std::env::temp_dir().join(format!( + "openplotva-sandbox-http-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + let sessions = Arc::new(SessionManager::new( + HomeStorage::PlainDir, + root.clone(), + None, + SessionLimits { + max_sessions: 1, + idle_ttl: Duration::from_secs(60), + max_age: Duration::from_secs(300), + slot_wait: Duration::from_millis(20), + }, + )); + let state = Arc::new(AppState { + sessions, + runner: Arc::new(EchoRunner), + max_listed_files: 10, + max_read_bytes: 1024, + max_input_bytes: 1024, + }); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + axum::serve(listener, router(state, 1024 * 1024)) + .await + .expect("serve"); + }); + let client = SandboxClient::new(&format!("http://{address}"), Duration::from_secs(5)) + .expect("client"); + (client, root) + } + + #[tokio::test] + async fn run_writes_inputs_lists_outputs_and_serves_them() { + let (client, root) = serve("run").await; + let request = RunRequest { + code: "print(1)".to_owned(), + files: vec![InputFile { + path: "in.txt".to_owned(), + content_base64: BASE64.encode("данные"), + }], + }; + let response = client.run("chat-1", &request).await.expect("run"); + assert_eq!(response.exit_code, Some(0)); + assert_eq!(response.stdout, "done\n"); + assert!(response.session.created); + assert_eq!( + response.files, + vec![ + FileEntry { + path: "out.txt".to_owned(), + size: "print(1)|данные".len() as u64, + changed: true, + }, + FileEntry { + path: "in.txt".to_owned(), + size: "данные".len() as u64, + changed: false, + }, + ] + ); + + let again = client.run("chat-1", &request).await.expect("second run"); + assert!(!again.session.created); + + let bytes = client.read_file("chat-1", "out.txt").await.expect("read"); + assert_eq!(String::from_utf8(bytes).expect("utf8"), "print(1)|данные"); + assert!(matches!( + client.read_file("chat-1", "missing.png").await, + Err(SandboxClientError::NotFound { .. }) + )); + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn rejects_bad_requests_and_reports_busy_and_expired_sessions() { + let (client, root) = serve("errors").await; + let ok = RunRequest { + code: "x".to_owned(), + files: Vec::new(), + }; + assert!(matches!( + client.run("bad.key", &ok).await, + Err(SandboxClientError::Rejected { code, .. }) if code == ERROR_INVALID_REQUEST + )); + let escape = RunRequest { + code: "x".to_owned(), + files: vec![InputFile { + path: "../escape".to_owned(), + content_base64: BASE64.encode("x"), + }], + }; + assert!(matches!( + client.run("a", &escape).await, + Err(SandboxClientError::Rejected { code, .. }) if code == ERROR_INVALID_REQUEST + )); + assert!(matches!( + client.run("b", &ok).await, + Err(SandboxClientError::Busy(_)) + )); + assert!(matches!( + client.read_file("zzz", "out.txt").await, + Err(SandboxClientError::NotFound { code, .. }) if code == ERROR_SESSION_NOT_FOUND + )); + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/crates/openplotva-sandbox/src/server/mod.rs b/crates/openplotva-sandbox/src/server/mod.rs new file mode 100644 index 00000000..2f7de143 --- /dev/null +++ b/crates/openplotva-sandbox/src/server/mod.rs @@ -0,0 +1,138 @@ +//! The supervisor: session homes, nsjail runs, and the HTTP API. + +pub mod config; +pub mod home; +pub mod http; +pub mod runner; +pub mod session; + +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context; + +pub use config::SupervisorConfig; +use home::HomeStorage; +use runner::NsjailRunner; +use session::{SessionLimits, SessionManager}; + +const CGROUP_ROOT: &str = "/sys/fs/cgroup"; +const REAP_INTERVAL: Duration = Duration::from_secs(1); + +/// Run the supervisor until SIGTERM or Ctrl-C. +pub async fn serve(config: SupervisorConfig) -> anyhow::Result<()> { + enable_cgroup_v2_controllers(Path::new(CGROUP_ROOT)) + .context("nsjail needs writable cgroup v2 controllers")?; + reset_dir(&config.sessions_dir).await?; + let scratch = config.sessions_dir.join(".calls"); + tokio::fs::create_dir_all(&scratch).await?; + + let sessions = Arc::new(SessionManager::new( + HomeStorage::Tmpfs { + size_mib: config.home_size_mib, + }, + config.sessions_dir.clone(), + config.matplotlib_cache.clone(), + SessionLimits { + max_sessions: config.max_sessions, + idle_ttl: config.idle_ttl, + max_age: config.max_age, + slot_wait: config.slot_wait, + }, + )); + let runner = Arc::new(NsjailRunner::new( + config.nsjail_path.clone(), + config.nsjail_config.clone(), + config.python.clone(), + scratch, + config.call_timeout, + config.max_output_bytes, + )); + let state = Arc::new(http::AppState { + sessions: Arc::clone(&sessions), + runner, + max_listed_files: config.max_listed_files, + max_read_bytes: config.max_read_bytes, + max_input_bytes: config.home_size_mib * 1024 * 1024, + }); + + let reaper = { + let sessions = Arc::clone(&sessions); + tokio::spawn(async move { + let mut ticker = tokio::time::interval(REAP_INTERVAL); + loop { + ticker.tick().await; + sessions.reap().await; + } + }) + }; + + let listener = tokio::net::TcpListener::bind(config.bind) + .await + .with_context(|| format!("bind {}", config.bind))?; + tracing::info!(bind = %config.bind, max_sessions = config.max_sessions, "sandbox supervisor listening"); + axum::serve(listener, http::router(state, config.max_request_bytes)) + .with_graceful_shutdown(shutdown_signal()) + .await?; + reaper.abort(); + sessions.close_all().await; + Ok(()) +} + +/// Move every process into a child cgroup and enable all controllers for +/// children, so nsjail can create a cgroup per run. A no-op when the +/// controllers are already enabled. +fn enable_cgroup_v2_controllers(root: &Path) -> std::io::Result<()> { + let subtree = root.join("cgroup.subtree_control"); + if !std::fs::read_to_string(&subtree)?.trim().is_empty() { + return Ok(()); + } + let init = root.join("init"); + std::fs::create_dir_all(&init)?; + for pid in std::fs::read_to_string(root.join("cgroup.procs"))?.split_whitespace() { + if let Err(error) = std::fs::write(init.join("cgroup.procs"), pid) { + tracing::debug!(pid, %error, "could not move process into the init cgroup"); + } + } + for controller in std::fs::read_to_string(root.join("cgroup.controllers"))?.split_whitespace() { + std::fs::write(&subtree, format!("+{controller}"))?; + } + Ok(()) +} + +async fn reset_dir(dir: &Path) -> anyhow::Result<()> { + if let Ok(mut entries) = tokio::fs::read_dir(dir).await { + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + HomeStorage::Tmpfs { size_mib: 0 }.destroy(&path).await; + } + } + tokio::fs::create_dir_all(dir) + .await + .with_context(|| format!("create {}", dir.display())) +} + +async fn shutdown_signal() { + let ctrl_c = async { + if let Err(error) = tokio::signal::ctrl_c().await { + tracing::warn!(%error, "failed to listen for Ctrl-C"); + std::future::pending::<()>().await; + } + }; + let terminate = async { + match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + Ok(mut signal) => { + signal.recv().await; + } + Err(error) => { + tracing::warn!(%error, "failed to listen for SIGTERM"); + std::future::pending::<()>().await; + } + } + }; + tokio::select! { + () = ctrl_c => {} + () = terminate => {} + } +} diff --git a/crates/openplotva-sandbox/src/server/runner.rs b/crates/openplotva-sandbox/src/server/runner.rs new file mode 100644 index 00000000..b0f2a26d --- /dev/null +++ b/crates/openplotva-sandbox/src/server/runner.rs @@ -0,0 +1,254 @@ +//! One script execution under nsjail. + +use std::future::Future; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use std::process::Stdio; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Command; + +use crate::api::Termination; + +/// Path of the script inside the jail. +pub const SCRIPT_PATH: &str = "/sandbox/main.py"; + +/// What one run produced. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct RunOutcome { + pub termination: Termination, + pub exit_code: Option, + pub stdout: String, + pub stdout_truncated: bool, + pub stderr: String, + pub stderr_truncated: bool, + pub duration: Duration, +} + +/// Boxed future returned by [`CodeRunner::run`]. +pub type RunFuture<'a> = Pin + Send + 'a>>; + +/// Runs one script with `home` mounted as the working directory. +pub trait CodeRunner: Send + Sync + 'static { + fn run<'a>(&'a self, home: &'a Path, code: &'a str) -> RunFuture<'a>; +} + +/// Runs scripts through the nsjail binary and the sandbox config. +#[derive(Debug)] +pub struct NsjailRunner { + nsjail: PathBuf, + config: PathBuf, + python: PathBuf, + scratch: PathBuf, + backstop: Duration, + max_output_bytes: usize, + counter: AtomicU64, +} + +impl NsjailRunner { + #[must_use] + pub fn new( + nsjail: PathBuf, + config: PathBuf, + python: PathBuf, + scratch: PathBuf, + backstop: Duration, + max_output_bytes: usize, + ) -> Self { + Self { + nsjail, + config, + python, + scratch, + backstop, + max_output_bytes, + counter: AtomicU64::new(0), + } + } + + async fn run_inner(&self, home: &Path, code: &str) -> RunOutcome { + let started = Instant::now(); + let call = self.counter.fetch_add(1, Ordering::Relaxed); + let script = self.scratch.join(format!("{call}.py")); + let log = self.scratch.join(format!("{call}.log")); + if let Err(error) = tokio::fs::write(&script, code).await { + return sandbox_error(started, format!("write script: {error}")); + } + let outcome = self.spawn(home, &script, &log, started).await; + for path in [&script, &log] { + if let Err(error) = tokio::fs::remove_file(path).await { + tracing::debug!(path = %path.display(), %error, "failed to remove scratch file"); + } + } + outcome + } + + async fn spawn(&self, home: &Path, script: &Path, log: &Path, started: Instant) -> RunOutcome { + let mut command = Command::new(&self.nsjail); + command + .arg("--config") + .arg(&self.config) + .arg("--log") + .arg(log) + .arg("--bindmount") + .arg(format!("{}:/home", home.display())) + .arg("--bindmount_ro") + .arg(format!("{}:{SCRIPT_PATH}", script.display())) + .arg("--") + .arg(&self.python) + .arg(SCRIPT_PATH) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = match command.spawn() { + Ok(child) => child, + Err(error) => return sandbox_error(started, format!("spawn nsjail: {error}")), + }; + let (Some(stdout), Some(stderr)) = (child.stdout.take(), child.stderr.take()) else { + return sandbox_error(started, "nsjail pipes are missing".to_owned()); + }; + let limit = self.max_output_bytes; + let stdout_task = tokio::spawn(read_capped(stdout, limit)); + let stderr_task = tokio::spawn(read_capped(stderr, limit)); + let status = tokio::time::timeout(self.backstop, child.wait()).await; + let backstop_hit = status.is_err(); + if backstop_hit && let Err(error) = child.kill().await { + tracing::warn!(%error, "failed to kill nsjail after the backstop timeout"); + } + let (stdout, stdout_truncated) = stdout_task.await.unwrap_or_default(); + let (stderr, stderr_truncated) = stderr_task.await.unwrap_or_default(); + let log_text = tokio::fs::read_to_string(log).await.unwrap_or_default(); + let nsjail_code = match status { + Ok(Ok(status)) => status.code(), + Ok(Err(error)) => { + return sandbox_error(started, format!("wait for nsjail: {error}")); + } + Err(_) => None, + }; + let (termination, exit_code) = if backstop_hit { + (Termination::Timeout, None) + } else { + classify(&log_text, nsjail_code) + }; + if termination == Termination::SandboxError { + tracing::error!(log = %error_lines(&log_text), "nsjail failed to run the script"); + } + RunOutcome { + termination, + exit_code, + stdout, + stdout_truncated, + stderr, + stderr_truncated, + duration: started.elapsed(), + } + } +} + +impl CodeRunner for NsjailRunner { + fn run<'a>(&'a self, home: &'a Path, code: &'a str) -> RunFuture<'a> { + Box::pin(self.run_inner(home, code)) + } +} + +fn sandbox_error(started: Instant, message: String) -> RunOutcome { + tracing::error!(%message, "sandbox run failed"); + RunOutcome { + termination: Termination::SandboxError, + stderr: "The sandbox could not start the script.".to_owned(), + duration: started.elapsed(), + ..RunOutcome::default() + } +} + +/// Map the nsjail log and exit code to a termination. +/// +/// The jailed interpreter runs as PID 1 of its namespace, so nsjail reports +/// its fate in the log: an exit status, a killing signal, or the time limit. +fn classify(log: &str, nsjail_code: Option) -> (Termination, Option) { + if log.contains("run time >= time limit") { + return (Termination::Timeout, None); + } + if log.contains("terminated with signal") { + return (Termination::Killed, None); + } + if let Some(code) = log + .lines() + .rev() + .find_map(|line| line.split("exited with status: ").nth(1)) + .and_then(|rest| rest.split(',').next()) + .and_then(|code| code.trim().parse::().ok()) + { + return (Termination::Exited, Some(code)); + } + match nsjail_code { + Some(code) if code < 128 => (Termination::Exited, Some(code)), + _ => (Termination::SandboxError, None), + } +} + +fn error_lines(log: &str) -> String { + log.lines() + .filter(|line| line.starts_with("[E]") || line.starts_with("[F]")) + .collect::>() + .join("\n") +} + +async fn read_capped(mut reader: impl AsyncRead + Unpin, limit: usize) -> (String, bool) { + let mut kept = Vec::new(); + let mut truncated = false; + let mut buffer = [0_u8; 8192]; + loop { + match reader.read(&mut buffer).await { + Ok(0) | Err(_) => break, + Ok(read) => { + let room = limit.saturating_sub(kept.len()); + if read > room { + truncated = true; + } + kept.extend_from_slice(&buffer[..read.min(room)]); + } + } + } + (String::from_utf8_lossy(&kept).into_owned(), truncated) +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXIT_3: &str = "[I][t] Executing '/py' for '[STANDALONE MODE]'\n\ + [I][t] pid=10 ([STANDALONE MODE]) exited with status: 3, (PIDs left: 0)\n"; + const TIMEOUT: &str = "[I][t] pid=16 run time >= time limit (30 >= 30) ([STANDALONE MODE]). Killing it\n\ + [I][t] pid=16 ([STANDALONE MODE]) terminated with signal: SIGKILL (9), (PIDs left: 0)\n"; + const OOM: &str = + "[I][t] pid=22 ([STANDALONE MODE]) terminated with signal: SIGKILL (9), (PIDs left: 0)\n"; + const FATAL: &str = "[F][t] main():359 Couldn't prepare sandboxing policy\n"; + + #[test] + fn classifies_nsjail_outcomes_from_its_log() { + assert_eq!(classify(EXIT_3, Some(3)), (Termination::Exited, Some(3))); + assert_eq!(classify(TIMEOUT, Some(137)), (Termination::Timeout, None)); + assert_eq!(classify(OOM, Some(137)), (Termination::Killed, None)); + assert_eq!( + classify(FATAL, Some(255)), + (Termination::SandboxError, None) + ); + assert_eq!(classify("", None), (Termination::SandboxError, None)); + assert_eq!(classify("", Some(0)), (Termination::Exited, Some(0))); + } + + #[tokio::test] + async fn output_is_capped_without_blocking_the_writer() { + let data = vec![b'x'; 20_000]; + let (text, truncated) = read_capped(&data[..], 100).await; + assert_eq!(text.len(), 100); + assert!(truncated); + let (text, truncated) = read_capped(&b"short"[..], 100).await; + assert_eq!(text, "short"); + assert!(!truncated); + } +} diff --git a/crates/openplotva-sandbox/src/server/session.rs b/crates/openplotva-sandbox/src/server/session.rs new file mode 100644 index 00000000..53c84c2e --- /dev/null +++ b/crates/openplotva-sandbox/src/server/session.rs @@ -0,0 +1,404 @@ +//! Session lifecycle: one home per key, at most `max_sessions` alive, closed +//! after `idle_ttl` without calls or `max_age` after opening. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard}; +use std::time::{Duration, Instant}; + +use thiserror::Error; +use tokio::sync::{Mutex as AsyncMutex, OwnedMutexGuard, OwnedSemaphorePermit, Semaphore}; + +use super::home::HomeStorage; +use crate::api::SessionInfo; + +/// One live session. +#[derive(Debug)] +pub struct Session { + key: String, + home: PathBuf, + opened: Instant, + last_used: Mutex, + run_lock: Arc>, + closed: AtomicBool, + _slot: OwnedSemaphorePermit, +} + +impl Session { + #[must_use] + pub fn home(&self) -> &Path { + &self.home + } + + fn touch(&self, now: Instant) { + *lock(&self.last_used) = now; + } + + fn expired(&self, now: Instant, idle_ttl: Duration, max_age: Duration) -> bool { + now.duration_since(*lock(&self.last_used)) >= idle_ttl + || now.duration_since(self.opened) >= max_age + } +} + +/// Exclusive use of a session for one call; the session stays alive while held. +#[derive(Debug)] +pub struct SessionLease { + _guard: OwnedMutexGuard<()>, + session: Arc, + /// True when this lease opened the session. + pub created: bool, + idle_ttl: Duration, + max_age: Duration, +} + +impl SessionLease { + #[must_use] + pub fn home(&self) -> &Path { + self.session.home() + } + + /// Mark the session used now and describe its remaining lifetime. + #[must_use] + pub fn finish(&self) -> SessionInfo { + let now = Instant::now(); + self.session.touch(now); + let age = now.duration_since(self.session.opened); + let remaining = self.max_age.saturating_sub(age); + SessionInfo { + created: self.created, + idle_expires_in_seconds: self.idle_ttl.min(remaining).as_secs(), + expires_in_seconds: remaining.as_secs(), + } + } +} + +/// Error returned when a session cannot be opened. +#[derive(Debug, Error)] +pub enum SessionError { + #[error("all {0} sandbox sessions are busy")] + Busy(usize), + #[error("session home: {0}")] + Home(#[from] std::io::Error), +} + +/// Owns every session and its slot. +#[derive(Debug)] +pub struct SessionManager { + sessions: Mutex>>, + slots: Arc, + max_sessions: usize, + storage: HomeStorage, + root: PathBuf, + matplotlib_cache: Option, + idle_ttl: Duration, + max_age: Duration, + slot_wait: Duration, + counter: AtomicU64, +} + +/// Session settings for [`SessionManager::new`]. +#[derive(Clone, Debug)] +pub struct SessionLimits { + pub max_sessions: usize, + pub idle_ttl: Duration, + pub max_age: Duration, + pub slot_wait: Duration, +} + +impl SessionManager { + #[must_use] + pub fn new( + storage: HomeStorage, + root: PathBuf, + matplotlib_cache: Option, + limits: SessionLimits, + ) -> Self { + Self { + sessions: Mutex::new(HashMap::new()), + slots: Arc::new(Semaphore::new(limits.max_sessions)), + max_sessions: limits.max_sessions, + storage, + root, + matplotlib_cache, + idle_ttl: limits.idle_ttl, + max_age: limits.max_age, + slot_wait: limits.slot_wait, + counter: AtomicU64::new(0), + } + } + + #[must_use] + pub fn storage(&self) -> &HomeStorage { + &self.storage + } + + /// Number of live sessions. + #[must_use] + pub fn live(&self) -> usize { + lock(&self.sessions).len() + } + + /// Take exclusive use of the session for `key`, opening one when it is + /// missing or expired. + pub async fn lease(&self, key: &str) -> Result { + loop { + let (session, created) = match self.existing(key) { + Some(session) => (session, false), + None => (self.open(key).await?, true), + }; + let guard = Arc::clone(&session.run_lock).lock_owned().await; + if session.closed.load(Ordering::Acquire) { + continue; + } + session.touch(Instant::now()); + return Ok(SessionLease { + session, + _guard: guard, + created, + idle_ttl: self.idle_ttl, + max_age: self.max_age, + }); + } + } + + /// Take exclusive use of a live session without opening one. + pub async fn lease_existing(&self, key: &str) -> Option { + let session = self.existing(key)?; + let guard = Arc::clone(&session.run_lock).lock_owned().await; + (!session.closed.load(Ordering::Acquire)).then(|| SessionLease { + session, + _guard: guard, + created: false, + idle_ttl: self.idle_ttl, + max_age: self.max_age, + }) + } + + /// Close the session for `key` now. + pub async fn close(&self, key: &str) -> bool { + let Some(session) = lock(&self.sessions).remove(key) else { + return false; + }; + self.destroy(session).await; + true + } + + /// Close every expired session that is not running a call. + pub async fn reap(&self) -> usize { + let now = Instant::now(); + let expired = { + let mut sessions = lock(&self.sessions); + let keys = sessions + .iter() + .filter(|(_, session)| { + session.run_lock.try_lock().is_ok() + && session.expired(now, self.idle_ttl, self.max_age) + }) + .map(|(key, _)| key.clone()) + .collect::>(); + keys.into_iter() + .filter_map(|key| sessions.remove(&key)) + .collect::>() + }; + let count = expired.len(); + for session in expired { + self.destroy(session).await; + } + count + } + + /// Close every session, for shutdown. + pub async fn close_all(&self) { + let sessions = lock(&self.sessions) + .drain() + .map(|(_, session)| session) + .collect::>(); + for session in sessions { + self.destroy(session).await; + } + } + + fn existing(&self, key: &str) -> Option> { + let now = Instant::now(); + let mut sessions = lock(&self.sessions); + let session = sessions.get(key)?; + if session.expired(now, self.idle_ttl, self.max_age) && session.run_lock.try_lock().is_ok() + { + let stale = sessions.remove(key); + drop(sessions); + if let Some(stale) = stale { + stale.closed.store(true, Ordering::Release); + let storage = self.storage.clone(); + tokio::spawn(async move { + let _guard = stale.run_lock.lock().await; + storage.destroy(&stale.home).await; + }); + } + return None; + } + Some(Arc::clone(session)) + } + + async fn open(&self, key: &str) -> Result, SessionError> { + let slot = tokio::time::timeout(self.slot_wait, Arc::clone(&self.slots).acquire_owned()) + .await + .map_err(|_| SessionError::Busy(self.max_sessions))? + .map_err(|_| SessionError::Busy(self.max_sessions))?; + if let Some(session) = self.existing(key) { + return Ok(session); + } + let sequence = self.counter.fetch_add(1, Ordering::Relaxed); + let home = self.root.join(format!("{key}.{sequence}")); + if let Err(error) = self + .storage + .create(&home, self.matplotlib_cache.as_deref()) + .await + { + self.storage.destroy(&home).await; + return Err(error.into()); + } + let now = Instant::now(); + let session = Arc::new(Session { + key: key.to_owned(), + home, + opened: now, + last_used: Mutex::new(now), + run_lock: Arc::new(AsyncMutex::new(())), + closed: AtomicBool::new(false), + _slot: slot, + }); + let previous = lock(&self.sessions).insert(key.to_owned(), Arc::clone(&session)); + if let Some(previous) = previous { + self.destroy(previous).await; + } + tracing::info!(%key, home = %session.home.display(), "sandbox session opened"); + Ok(session) + } + + async fn destroy(&self, session: Arc) { + session.closed.store(true, Ordering::Release); + let _guard = session.run_lock.lock().await; + self.storage.destroy(&session.home).await; + tracing::info!(key = %session.key, "sandbox session closed"); + } +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn manager(name: &str, limits: SessionLimits) -> (SessionManager, PathBuf) { + let root = std::env::temp_dir().join(format!( + "openplotva-sandbox-sessions-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + ( + SessionManager::new(HomeStorage::PlainDir, root.clone(), None, limits), + root, + ) + } + + fn limits(idle_ms: u64, age_ms: u64) -> SessionLimits { + SessionLimits { + max_sessions: 2, + idle_ttl: Duration::from_millis(idle_ms), + max_age: Duration::from_millis(age_ms), + slot_wait: Duration::from_millis(50), + } + } + + #[tokio::test] + async fn reuses_a_live_session_and_keeps_its_files() { + let (sessions, root) = manager("reuse", limits(5_000, 60_000)); + let first = sessions.lease("chat-1").await.expect("open"); + assert!(first.created); + std::fs::write(first.home().join("data.txt"), b"1").expect("write"); + let info = first.finish(); + assert!(info.created); + assert!(info.idle_expires_in_seconds <= 5); + drop(first); + + let second = sessions.lease("chat-1").await.expect("reuse"); + assert!(!second.created); + assert!(second.home().join("data.txt").is_file()); + drop(second); + assert_eq!(sessions.live(), 1); + sessions.close_all().await; + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn refuses_a_new_session_when_every_slot_is_taken() { + let (sessions, root) = manager("busy", limits(5_000, 60_000)); + let _a = sessions.lease("a").await.expect("a"); + let _b = sessions.lease("b").await.expect("b"); + assert!(matches!( + sessions.lease("c").await, + Err(SessionError::Busy(2)) + )); + drop(_a); + drop(_b); + sessions.close_all().await; + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn idle_sessions_are_reaped_and_their_slot_freed() { + let (sessions, root) = manager("idle", limits(40, 60_000)); + let lease = sessions.lease("a").await.expect("open"); + let home = lease.home().to_path_buf(); + let _ = lease.finish(); + drop(lease); + assert_eq!(sessions.reap().await, 0); + tokio::time::sleep(Duration::from_millis(60)).await; + assert_eq!(sessions.reap().await, 1); + assert!(!home.exists()); + assert_eq!(sessions.live(), 0); + + let _a = sessions.lease("a").await.expect("slot freed"); + let _b = sessions.lease("b").await.expect("second slot"); + drop(_a); + drop(_b); + sessions.close_all().await; + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn calls_keep_a_session_alive_only_until_its_maximum_age() { + let (sessions, root) = manager("age", limits(1_000, 150)); + let first = sessions.lease("a").await.expect("open"); + let first_home = first.home().to_path_buf(); + drop(first); + tokio::time::sleep(Duration::from_millis(100)).await; + let touched = sessions.lease("a").await.expect("still alive"); + assert!(!touched.created); + drop(touched); + tokio::time::sleep(Duration::from_millis(80)).await; + let renewed = sessions.lease("a").await.expect("reopened"); + assert!(renewed.created); + assert_ne!(renewed.home(), first_home.as_path()); + drop(renewed); + sessions.close_all().await; + let _ = std::fs::remove_dir_all(root); + } + + #[tokio::test] + async fn a_running_call_blocks_expiry() { + let (sessions, root) = manager("running", limits(20, 60_000)); + let lease = sessions.lease("a").await.expect("open"); + tokio::time::sleep(Duration::from_millis(40)).await; + assert_eq!(sessions.reap().await, 0); + assert!(sessions.lease_existing("b").await.is_none()); + drop(lease); + sessions.close_all().await; + let _ = std::fs::remove_dir_all(root); + } +} diff --git a/crates/openplotva-telegram/src/lib.rs b/crates/openplotva-telegram/src/lib.rs index c909a7a0..74702105 100644 --- a/crates/openplotva-telegram/src/lib.rs +++ b/crates/openplotva-telegram/src/lib.rs @@ -65,10 +65,11 @@ pub use outbound::{ ReplyParametersPlan, RichMessageRequest, SETTINGS_BUTTON_TEXT, SUBSCRIPTION_DURATION_DAYS, SUBSCRIPTION_PERIOD_SECONDS, SUBSCRIPTION_PRICE_STARS, StickerMessagePlan, StickerMessageRequest, SubscriptionInvoiceLinkRequest, TELEGRAM_STARS_CURRENCY, - TELEGRAM_TEXT_MAX_BYTES, TextMessageRequest, VIP_SUBSCRIPTION_DESCRIPTION, - VIP_SUBSCRIPTION_TITLE, allow_sending_without_reply, build_audio_message_method, - build_audio_message_plan, build_callback_answer_method, build_cancel_star_subscription_method, - build_chat_action_method, build_delete_message_method, build_donation_invoice_link_method, + TELEGRAM_TEXT_MAX_BYTES, TextMessageRequest, UploadMessageRequest, + VIP_SUBSCRIPTION_DESCRIPTION, VIP_SUBSCRIPTION_TITLE, allow_sending_without_reply, + build_animation_message_method, build_audio_message_method, build_audio_message_plan, + build_callback_answer_method, build_cancel_star_subscription_method, build_chat_action_method, + build_delete_message_method, build_document_message_method, build_donation_invoice_link_method, build_edit_caption_message_method, build_edit_media_message_method, build_edit_media_message_plan, build_edit_reply_markup_message_method, build_edit_text_message_method, build_get_chat_administrators_method, @@ -85,13 +86,13 @@ pub use outbound::{ build_subscription_invoice_link_method, build_text_message_method, build_text_message_method_without_link_preview, build_text_message_methods, build_text_message_methods_without_link_previews, - build_text_message_methods_without_link_previews_with_atomic_tail, classify_payment_payload, - donation_invoice_payload, fingerprint_audio_message_plan, fingerprint_message_reaction, - fingerprint_photo_message_plan, fingerprint_rich_message, fingerprint_sticker_message_plan, - fingerprint_text_message_part, forum_thread_id, guest_add_to_chat_url, - guest_dialog_fallback_html, guest_inline_description, guest_inline_result_id, - guest_unsupported_feature_html, hash_content, message_target_chat, parse_mode_from_go, - parse_payment_payload, prepare_guest_html, subscription_invoice_payload, + build_text_message_methods_without_link_previews_with_atomic_tail, build_video_message_method, + classify_payment_payload, donation_invoice_payload, fingerprint_audio_message_plan, + fingerprint_message_reaction, fingerprint_photo_message_plan, fingerprint_rich_message, + fingerprint_sticker_message_plan, fingerprint_text_message_part, forum_thread_id, + guest_add_to_chat_url, guest_dialog_fallback_html, guest_inline_description, + guest_inline_result_id, guest_unsupported_feature_html, hash_content, message_target_chat, + parse_mode_from_go, parse_payment_payload, prepare_guest_html, subscription_invoice_payload, subscription_invoice_price_stars, telegram_member_can_open_group_settings, validate_text_message_text, }; diff --git a/crates/openplotva-telegram/src/outbound.rs b/crates/openplotva-telegram/src/outbound.rs index 1b9cfe2a..93d2b1a4 100644 --- a/crates/openplotva-telegram/src/outbound.rs +++ b/crates/openplotva-telegram/src/outbound.rs @@ -9,8 +9,8 @@ use carapax::types::{ InlineQueryResultArticle, InputFile, InputFileReader, InputMedia, InputMediaPhoto, InputMessageContentText, InputText, InvoiceParameters, LabeledPrice, LinkPreviewOptions, MediaGroup, MediaGroupError, MediaGroupItem, ParseMode, ReactionType, RefundStarPayment, - ReplyMarkup, ReplyParameters, SendAudio, SendChatAction, SendMediaGroup, SendMessage, - SendPhoto, SendSticker, SetMessageReaction, WebAppInfo, + ReplyMarkup, ReplyParameters, SendAnimation, SendAudio, SendChatAction, SendDocument, + SendMediaGroup, SendMessage, SendPhoto, SendSticker, SendVideo, SetMessageReaction, WebAppInfo, }; use crc::{CRC_32_ISCSI, Crc}; use serde_json::{Map, Value, json}; @@ -432,6 +432,25 @@ pub struct AudioMessagePlan { pub reply_parameters: Option, } +/// One uploaded file sent with `sendDocument`, `sendVideo` or `sendAnimation`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct UploadMessageRequest { + /// Target chat. + pub chat: ChatRef, + /// Target topic ID for forum chats. + pub message_thread_id: i64, + /// Whether Telegram should suppress user notification sound. + pub disable_notification: bool, + /// Multipart file name. + pub file_name: String, + /// File bytes. + pub bytes: Vec, + /// Optional Telegram caption. + pub caption: String, + pub render_as: String, + pub reply_parameters: Option, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct MediaGroupPhotoItem { /// Photo file, URL, or upload bytes. @@ -1217,6 +1236,62 @@ pub fn build_audio_message_plan(req: &AudioMessageRequest) -> AudioMessagePlan { } } +/// Apply the options every upload method shares. +macro_rules! upload_message_method { + ($method:expr, $req:expr) => {{ + let req: &UploadMessageRequest = $req; + let mut method = $method; + if req.message_thread_id != 0 { + method = method.with_message_thread_id(req.message_thread_id); + } + if let Some(topic_id) = req.chat.direct_messages_topic_id { + method = method.with_direct_messages_topic_id(topic_id); + } + if req.disable_notification { + method = method.with_disable_notification(true); + } + if !req.caption.is_empty() { + method = match parse_mode_from_go(&req.render_as)? { + Some(parse_mode) => method + .with_caption(InputText::from(req.caption.clone()).with_format(parse_mode)), + None => method.with_caption(req.caption.clone()), + }; + } + if let Some(reply) = req.reply_parameters { + method = method.with_reply_parameters(reply.into_carapax()); + } + Ok(method) + }}; +} + +fn upload_input_file(req: &UploadMessageRequest) -> InputFileReader { + InputFileReader::new(Cursor::new(req.bytes.clone())).with_file_name(req.file_name.clone()) +} + +/// Build an outbound `sendDocument` method. +pub fn build_document_message_method( + req: &UploadMessageRequest, +) -> Result { + upload_message_method!(SendDocument::new(req.chat.id, upload_input_file(req)), req) +} + +/// Build an outbound streamable `sendVideo` method. +pub fn build_video_message_method( + req: &UploadMessageRequest, +) -> Result { + upload_message_method!( + SendVideo::new(req.chat.id, upload_input_file(req)).with_supports_streaming(true), + req + ) +} + +/// Build an outbound `sendAnimation` method. +pub fn build_animation_message_method( + req: &UploadMessageRequest, +) -> Result { + upload_message_method!(SendAnimation::new(upload_input_file(req), req.chat.id), req) +} + /// Build an outbound `sendMediaGroup` method. pub fn build_media_group_message_method( req: &MediaGroupMessageRequest, @@ -1872,18 +1947,18 @@ mod tests { OutboundBuildError, PaymentPayload, PaymentPayloadKind, PhotoMessagePlan, PhotoMessageRequest, PhotoSource, ReplyMessageRef, ReplyParametersPlan, RichMessageRequest, StickerMessagePlan, StickerMessageRequest, SubscriptionInvoiceLinkRequest, - TextMessageRequest, allow_sending_without_reply, build_audio_message_method, - build_audio_message_plan, build_callback_answer_method, - build_cancel_star_subscription_method, build_chat_action_method, - build_delete_message_method, build_donation_invoice_link_method, - build_edit_caption_message_method, build_edit_media_message_method, - build_edit_media_message_plan, build_edit_reply_markup_message_method, - build_edit_text_message_method, build_get_chat_administrators_method, - build_get_chat_member_method, build_get_chat_method, build_guest_add_to_chat_markup, - build_guest_html_answer_method, build_guest_query_answer_method, - build_inline_keyboard_button_data, build_inline_keyboard_button_url, - build_inline_keyboard_button_web_app, build_inline_keyboard_markup, - build_inline_keyboard_row, build_inline_query_answer_method, + TextMessageRequest, UploadMessageRequest, allow_sending_without_reply, + build_animation_message_method, build_audio_message_method, build_audio_message_plan, + build_callback_answer_method, build_cancel_star_subscription_method, + build_chat_action_method, build_delete_message_method, build_document_message_method, + build_donation_invoice_link_method, build_edit_caption_message_method, + build_edit_media_message_method, build_edit_media_message_plan, + build_edit_reply_markup_message_method, build_edit_text_message_method, + build_get_chat_administrators_method, build_get_chat_member_method, build_get_chat_method, + build_guest_add_to_chat_markup, build_guest_html_answer_method, + build_guest_query_answer_method, build_inline_keyboard_button_data, + build_inline_keyboard_button_url, build_inline_keyboard_button_web_app, + build_inline_keyboard_markup, build_inline_keyboard_row, build_inline_query_answer_method, build_inline_query_result_article, build_media_group_message_method, build_media_group_message_plan, build_photo_message_method, build_photo_message_plan, build_pre_checkout_error_method, build_pre_checkout_ok_method, @@ -1892,13 +1967,13 @@ mod tests { build_subscription_invoice_link_method, build_text_message_method, build_text_message_methods, build_text_message_methods_without_link_previews, build_text_message_methods_without_link_previews_with_atomic_tail, - classify_payment_payload, donation_invoice_payload, fingerprint_audio_message_plan, - fingerprint_photo_message_plan, fingerprint_sticker_message_plan, - fingerprint_text_message_part, forum_thread_id, guest_add_to_chat_url, - guest_dialog_fallback_html, guest_inline_description, guest_inline_result_id, - guest_unsupported_feature_html, hash_content, message_target_chat, parse_payment_payload, - prepare_guest_html, subscription_invoice_payload, telegram_member_can_open_group_settings, - validate_text_message_text, + build_video_message_method, classify_payment_payload, donation_invoice_payload, + fingerprint_audio_message_plan, fingerprint_photo_message_plan, + fingerprint_sticker_message_plan, fingerprint_text_message_part, forum_thread_id, + guest_add_to_chat_url, guest_dialog_fallback_html, guest_inline_description, + guest_inline_result_id, guest_unsupported_feature_html, hash_content, message_target_chat, + parse_payment_payload, prepare_guest_html, subscription_invoice_payload, + telegram_member_can_open_group_settings, validate_text_message_text, }; use crate::{ InlineKeyboardButton, InlineKeyboardMarkup, ReplyMarkup, TELEGRAM_PARSE_MODE_HTML, @@ -2756,6 +2831,38 @@ mod tests { Ok(()) } + #[test] + fn upload_message_methods_accept_plain_and_html_captions() + -> Result<(), Box> { + let mut req = UploadMessageRequest { + chat: private_chat(42), + message_thread_id: 77, + disable_notification: false, + file_name: "report.xlsx".to_owned(), + bytes: vec![1, 2, 3], + caption: "report".to_owned(), + render_as: String::new(), + reply_parameters: Some(ReplyParametersPlan { + message_id: 9, + chat_id: 42, + allow_sending_without_reply: true, + }), + }; + let _method = build_document_message_method(&req)?; + + req.render_as = TELEGRAM_PARSE_MODE_HTML.to_owned(); + let _method = build_document_message_method(&req)?; + let _method = build_video_message_method(&req)?; + let _method = build_animation_message_method(&req)?; + + req.render_as = "BBCode".to_owned(); + assert!(matches!( + build_document_message_method(&req), + Err(OutboundBuildError::UnsupportedParseMode(_)) + )); + Ok(()) + } + #[test] fn audio_plan_persistence_payload_matches_go_audio_config() -> Result<(), Box> { diff --git a/deploy/production/compose.production.yml b/deploy/production/compose.production.yml index b4987acd..c991b6f2 100644 --- a/deploy/production/compose.production.yml +++ b/deploy/production/compose.production.yml @@ -34,6 +34,7 @@ services: OPENPLOTVA_RUN_MIGRATIONS: "true" OPENPLOTVA_CONSUME_UPDATES: "true" OPENPLOTVA_PRODUCE_UPDATES: "true" + SANDBOX_URL: ${SANDBOX_URL-http://sandbox:8090} ports: - "127.0.0.1:8080:8080" - "${OPENPLOTVA_RUNTIME_API_PUBLISH_HOST:?set OPENPLOTVA_RUNTIME_API_PUBLISH_HOST in the production env}:9091:9091" @@ -48,6 +49,7 @@ services: condition: service_healthy networks: - plotva-net + - sandbox-net restart: unless-stopped stop_grace_period: 60s healthcheck: @@ -62,6 +64,25 @@ services: max-size: "10m" max-file: "3" + # Runs model-written Python under nsjail. nsjail needs namespaces, mounts + # and cgroup v2 delegation, hence privileged; the network is internal, and + # every script also runs in its own empty network namespace. + sandbox: + image: ${OPENPLOTVA_SANDBOX_IMAGE:?OPENPLOTVA_SANDBOX_IMAGE is required} + privileged: true + cgroup: private + mem_limit: 2g + cpus: 2 + networks: + - sandbox-net + restart: unless-stopped + stop_grace_period: 15s + logging: + driver: local + options: + max-size: "10m" + max-file: "3" + postgresql: image: pgvector/pgvector:pg17-trixie environment: @@ -167,6 +188,9 @@ services: networks: plotva-net: driver: bridge + sandbox-net: + driver: bridge + internal: true volumes: openplotva-state: diff --git a/deploy/production/deploy-production.sh b/deploy/production/deploy-production.sh index b5130f45..4e832ea7 100755 --- a/deploy/production/deploy-production.sh +++ b/deploy/production/deploy-production.sh @@ -8,6 +8,7 @@ maintenance_env_file="${OPENPLOTVA_MAINTENANCE_ENV_FILE:-/etc/openplotva-mainten env_file="${deploy_root}/.env.production" project="${OPENPLOTVA_COMPOSE_PROJECT:-openplotva}" image="${OPENPLOTVA_DEPLOY_IMAGE:?OPENPLOTVA_DEPLOY_IMAGE is required}" +sandbox_image="${OPENPLOTVA_SANDBOX_IMAGE:?OPENPLOTVA_SANDBOX_IMAGE is required}" dragonfly_image="${DRAGONFLY_IMAGE:-docker.dragonflydb.io/dragonflydb/dragonfly:v1.38.1}" update_stream_valkey_image="${UPDATE_STREAM_VALKEY_IMAGE:-${UPDATE_STREAM_REDIS_IMAGE:-valkey/valkey:8.1-alpine}}" alpine_image="${OPENPLOTVA_DEPLOY_ALPINE_IMAGE:-alpine:3.20}" @@ -30,7 +31,7 @@ compose() { if [[ "$maintenance_overlay_active" == true ]]; then compose_args+=(--env-file "$maintenance_env_snapshot" -f "$maintenance_compose_file") fi - OPENPLOTVA_IMAGE="$image" DRAGONFLY_IMAGE="$dragonfly_image" UPDATE_STREAM_VALKEY_IMAGE="$update_stream_valkey_image" DB_POSTGRES_PASSWORD="$db_password" "${compose_args[@]}" "$@" + OPENPLOTVA_IMAGE="$image" OPENPLOTVA_SANDBOX_IMAGE="$sandbox_image" DRAGONFLY_IMAGE="$dragonfly_image" UPDATE_STREAM_VALKEY_IMAGE="$update_stream_valkey_image" DB_POSTGRES_PASSWORD="$db_password" "${compose_args[@]}" "$@" } env_file_has_key() { @@ -153,6 +154,7 @@ docker_login_and_pull() { [[ -n "${GHCR_USERNAME:-}" ]] || fail "GHCR_USERNAME is required" printf '%s' "$GHCR_PULL_TOKEN" | docker login ghcr.io -u "$GHCR_USERNAME" --password-stdin >/dev/null docker pull "$image" + docker pull "$sandbox_image" compose pull dragonfly redis-ingress } @@ -441,6 +443,38 @@ start_dependencies() { verify_update_stream_persistence } +start_sandbox() { + log "starting the Python sandbox" + compose up -d --no-deps sandbox + wait_for_service_health sandbox + smoke_sandbox +} + +# Health covers only the supervisor's HTTP listener; nsjail itself fails only +# when a script runs, so run one before the app starts offering the tool. +smoke_sandbox() { + local container + container="$(compose ps -q sandbox)" + [[ -n "$container" ]] || fail "sandbox container is missing" + docker exec "$container" python3 -c ' +import json, urllib.request +base = "http://127.0.0.1:8090/v1/sessions/deploy-smoke" +run = urllib.request.Request( + base + "/run", + data=json.dumps({"code": "print(6 * 7)"}).encode(), + headers={"Content-Type": "application/json"}, +) +result = json.load(urllib.request.urlopen(run, timeout=60)) +urllib.request.urlopen(urllib.request.Request(base, method="DELETE"), timeout=10) +ok = result["termination"] == "exited" and result.get("exit_code") == 0 and result["stdout"] == "42\n" +assert ok, result +' || { + docker logs --tail=60 "$container" >&2 || true + fail "the Python sandbox cannot run scripts" + } + log "sandbox ran a smoke script" +} + start_app() { ensure_state_volume log "recreating openplotva app" @@ -471,12 +505,14 @@ verify_app() { compose ps openplotva } -remove_non_current_app_images() { +remove_non_current_images() { + local container="$1" + local current_image="$2" local current_image_id local image_id local image_repository - current_image_id="$(docker inspect -f '{{.Image}}' "${project}-openplotva-1")" - image_repository="${image%:*}" + current_image_id="$(docker inspect -f '{{.Image}}' "$container")" + image_repository="${current_image%:*}" while IFS= read -r image_id; do [[ -n "$image_id" && "$image_id" != "$current_image_id" ]] || continue docker image rm -f "$image_id" >/dev/null @@ -487,7 +523,7 @@ remove_non_current_app_images() { --format '{{.ID}}' | sort -u ) - log "removed non-current local application images" + log "removed non-current local images of ${image_repository}" } main() { @@ -507,9 +543,11 @@ main() { fi start_dependencies "$postgres_mode" + start_sandbox start_app verify_app - remove_non_current_app_images + remove_non_current_images "${project}-openplotva-1" "$image" + remove_non_current_images "${project}-sandbox-1" "$sandbox_image" log "production deployment applied" } diff --git a/docs/CODEBASE_MAP.md b/docs/CODEBASE_MAP.md index 50cde614..5e6c63fe 100644 --- a/docs/CODEBASE_MAP.md +++ b/docs/CODEBASE_MAP.md @@ -154,6 +154,7 @@ graph BT llm[openplotva-llm]:::integration storage[openplotva-storage]:::integration server[openplotva-server]:::integration + sandbox[openplotva-sandbox]:::integration app[openplotva-app
composition root]:::root history --> core @@ -179,7 +180,7 @@ graph BT storage --> taskman server --> core obs --> config - app --> telegram & llm & storage & server & dialog & agent & memory & history & media & shield & taskman & updates & config & obs & prompts & web + app --> telegram & llm & storage & server & dialog & agent & memory & history & media & shield & taskman & updates & config & obs & prompts & web & sandbox classDef leaf fill:#1f2937,stroke:#374151,color:#e5e7eb classDef domain fill:#0f2a1d,stroke:#15803d,color:#bbf7d0 @@ -198,7 +199,7 @@ implementations together. ``` openplotva/ -├── crates/ # 18-crate Rust workspace (resolver = "3", edition 2024) +├── crates/ # 19-crate Rust workspace (resolver = "3", edition 2024) │ ├── openplotva-app/ # Composition root: config wiring, runtime startup, handlers, workers, HTTP │ │ └── src/ │ │ ├── main.rs # 6-line shim around run() @@ -218,6 +219,7 @@ openplotva/ │ ├── openplotva-memory/ # Memory extraction pipeline: cards, episodes, links, bitemporal retrieval │ ├── openplotva-history/ # Chat-history summary cascade (token fitting, coverage-cursor merge) │ ├── openplotva-media/ # Image optimizer, song director contract + farm music client, public uploader +│ ├── openplotva-sandbox/ # nsjail Python sandbox supervisor binary + the bot's HTTP client │ ├── openplotva-shield/ # Content-safety retrieval scoring + XML context injection │ ├── openplotva-updates/ # Redis update queue + enqueue-update CLI binary │ ├── openplotva-config/ # Typed AppConfig tree + env loader (single source of truth) @@ -227,6 +229,7 @@ openplotva/ ├── web/ │ ├── admin/ # Token-driven pl-* component library (design system, sha256-guarded) │ └── settings/ # Settings WebApp (Framework7 + Telegram theme, separate boundary) +├── sandbox/ # Sandbox image: Dockerfile, pinned requirements, nsjail policy ├── migrations/ # 152 numbered up/down SQL pairs (0_init → 151_retire_taskman_job_rollup) ├── prompts/ # .prompt templates (chat/aifarm/xml/memory/history/agentic/image/music/vision/youtube) ├── tools/ # Smoke scripts, Rust gates, db-reclaim, token-estimator microservice @@ -235,7 +238,7 @@ openplotva/ ├── docs/superpowers/{plans,specs}/# Design history (richest architectural context) ├── plans/ # Legacy numbered fix-plans (001-011) ├── skills/openplotva-*/ # Agent skills: deploy, design-system-review, runtime-api -├── Cargo.toml # Workspace manifest (18 members, sqlx 0.9, tokio, axum, async-graphql) +├── Cargo.toml # Workspace manifest (19 members, sqlx 0.9, tokio, axum, async-graphql) ├── DESIGN.md # Admin design-system contract (token tiers, pl-* catalog, guards) └── AGENTS.md # Operating rules, code style, architecture boundaries, delivery flow ``` @@ -424,6 +427,19 @@ canonicalization, instrumental/duration rules) plus the farm music client duration from `usage`), and the public media uploader (Telegram-safe URLs, preserves caller filenames). +#### `openplotva-sandbox` +Supervisor for the `run_python` dialog tool, deployed as its own privileged +container on an internal network. Each call runs `/sandbox/main.py` under +nsjail (no network, uid 65534, 256 MiB cgroup memory, 1 CPU, 30 s) with a +per-session 100 MiB tmpfs home mounted at `/home`. Sessions are keyed by chat +and thread, close 60 s after the last call or 5 min after opening, and at most +4 exist at once. HTTP API: `POST /v1/sessions/{key}/run`, +`GET /v1/sessions/{key}/files/{path}`, `DELETE /v1/sessions/{key}`. The app's +`python_sandbox.rs` uploads chat files into the session, calls the +supervisor, sends the requested output files to the chat, and records them in +history; stored `run_python` tool results carry the script so a follow-up turn +edits it instead of starting over. + #### `openplotva-memory` Extraction pipeline: `MemoryExtractor` → `ExtractOutput` (episode summary, candidate cards, resolutions/links) → validation (source-attribution, chat- diff --git a/docs/superpowers/specs/2026-10-20-python-sandbox-design.md b/docs/superpowers/specs/2026-10-20-python-sandbox-design.md new file mode 100644 index 00000000..63ca81a8 --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-python-sandbox-design.md @@ -0,0 +1,80 @@ +# Python sandbox (`run_python`) design + +## Intent + +Chat users ask the bot for things a language model cannot draw or compute +reliably on its own: bar charts, floor plans, tables from a pasted CSV or a +crawled page, file conversions, audio and video edits, exact calculations. +The `run_python` dialog tool lets the model write a Python script, run it in +an isolated sandbox with a fixed package set, and send the files it creates +to the chat. The script stays in dialog history, so "make the bars blue" edits +the earlier script instead of starting from scratch. + +## Goals + +- Run model-written Python with no network, no host access, and bounded + memory, CPU, time, processes and disk. +- Keep files between calls of one conversation for a short time, so a fix-up + call can reuse inputs and outputs. +- Copy chat files into the sandbox and send output files back to the chat. +- Keep the code of every call in the persisted tool history. + +## Non-goals + +- Package installation at run time, network access from scripts, or + long-running jobs. +- A vision check of the produced image before it is sent. + +## Sandbox + +The `openplotva-sandbox` binary is a small supervisor in its own container +image (`sandbox/Dockerfile`), built on the slim CPython image with nsjail +built from source and the packages from `sandbox/requirements.txt` installed +at build time. The package list covers data work (numpy, pandas, scipy, +sympy, statsmodels, scikit-learn, networkx, shapely), drawing (matplotlib, +seaborn, pillow, wordcloud, qrcode, graphviz, cairosvg), office and web +formats (openpyxl, xlrd, python-docx, pypdf, fpdf2, lxml, beautifulsoup4, +pyyaml) and media (ffmpeg-python with Debian's standard ffmpeg build). + +Every call is one nsjail process (`sandbox/nsjail/python.cfg`): + +| Limit | Value | +| --- | --- | +| Identity | uid/gid 65534 in a user namespace | +| Network | own network namespace without loopback | +| Memory | 256 MiB cgroup v2, no swap | +| CPU | 1 CPU, 30 s wall time, 60 s CPU rlimit | +| Processes | 16 pids | +| Files | 100 MiB per file, 128 descriptors | +| Filesystem | read-only system and venv mounts; writable `/home` only | +| Syscalls | kafel deny list for mount, namespace, ptrace, bpf, io_uring, module and keyring calls | + +A session is a 100 MiB tmpfs mounted at `/home`. It closes 60 s after its +last call and at most 5 minutes after it opened; at most 4 sessions exist at +once, and a new session waits up to 10 s for a free slot. The matplotlib font +cache is built into the image and copied into each home. + +The supervisor needs namespace, mount and cgroup privileges, so its container +runs privileged on an internal Docker network that only the bot shares. The +scripts themselves never see that network. + +## Tool contract + +`run_python(code, file_ids?, send_files?, caption?)`: + +- `file_ids` reference chat attachments. The bot resolves them only when the + file was seen in the same chat, downloads them (Telegram's 20 MB limit) and + uploads them into the session under their original or derived names. +- After a clean exit, each path in `send_files` (up to 10) is read back and + sent as a reply to the trigger message: images as photos or albums, `.gif` + as an animation, `.mp4` as a video, `.mp3`/`.m4a` as audio, anything else as + a document. A photo Telegram rejects is resent as a document. Sent messages + go into chat history and the file metadata store. +- The result carries the termination, exit code, clipped stdout and stderr, + the file list, input names and the delivery report. Failures map to + `python_error`, `python_timeout`, `python_killed`, `sandbox_busy` and + `sandbox_unavailable`, so the model can fix the script and retry. +- The stored tool result renders the call's `code` next to its output, which + is how a follow-up turn finds the script to edit. +- The tool is offered only when `SANDBOX_URL` is set. SAFE runtime virtual + dialogs run the script but withhold delivery. diff --git a/prompts/aifarm/system.prompt b/prompts/aifarm/system.prompt index 08855eac..0e5c0627 100644 --- a/prompts/aifarm/system.prompt +++ b/prompts/aifarm/system.prompt @@ -9,6 +9,7 @@ В случайном включении можно поставить реакцию или вызвать finish_turn без текста. При прямом обращении ответь или уточни. Команды и разговорные шорткаты задают намерение пользователя: выполни соответствующее действие через инструмент. Прямая просьба нарисовать, сгенерировать, переделать или отредактировать изображение требует вызова draw_image (draw_api для %). Не заменяй действие рассуждением, описанием, обещанием или вопросом «начать?». По известному запросу сразу вызови инструмент; уточняй только недостающую деталь, без которой результат нельзя получить. Обычное рисование не требует предварительного get_user_status: исполнитель сам проверит доступ и лимиты. Действующие ограничения контента и результат инструмента сохраняют силу. + Графики, диаграммы, схемы и планы по данным или размерам, таблицы, конвертацию файлов, обработку аудио и видео и точные расчёты делай через run_python: draw_image рисует иллюстрации, а не данные. Чтобы поправить прежний результат run_python, возьми его код из истории и измени только то, о чём просят. Для draw_image и draw_api всегда передавай caption: короткое название запрошенного изображения на языке пользователя. Убери обращение к боту, просьбу рисовать и слова вежливости. Сохрани смысл, имена и детали пользователя; название оформи естественно, с заглавной буквы. «Плотва, нарисуй собаквака» → caption «Собака-квака»; «Плотва, а нарисуй же мне, пожалуйста, золотую рыбку в кляре» → caption «Золотая рыбка в кляре». В prompt передавай отдельные, при необходимости обогащённые инструкции генератору. Не добавляй в caption придуманные для генератора детали и технический промпт. Если вызов вернул image_caption_required, добавь подпись и повтори вызов: генерация ещё не началась. diff --git a/sandbox/Dockerfile b/sandbox/Dockerfile new file mode 100644 index 00000000..0504e0db --- /dev/null +++ b/sandbox/Dockerfile @@ -0,0 +1,63 @@ +# syntax=docker/dockerfile:1.7 +# Python sandbox image: nsjail, CPython with the packages from +# requirements.txt, and the openplotva-sandbox supervisor. +# Build from the repository root: docker build -f sandbox/Dockerfile . +ARG PYTHON_IMAGE=python:3.14.8-slim-trixie +ARG RUST_VERSION=1.99.0 + +FROM debian:trixie-slim AS nsjail-build +ARG NSJAIL_REF=3.6 +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bison ca-certificates flex g++ git libnl-route-3-dev libprotobuf-dev \ + make pkg-config protobuf-compiler \ + && rm -rf /var/lib/apt/lists/* +RUN git clone --depth 1 --branch "${NSJAIL_REF}" https://github.com/google/nsjail.git /src/nsjail \ + && make -C /src/nsjail -j"$(nproc)" + +FROM ${PYTHON_IMAGE} AS venv +COPY sandbox/requirements.txt /tmp/requirements.txt +RUN python -m venv /opt/sandbox/venv \ + && /opt/sandbox/venv/bin/pip install --no-cache-dir --no-deps -r /tmp/requirements.txt \ + && /opt/sandbox/venv/bin/pip check \ + && /opt/sandbox/venv/bin/pip uninstall -y pip + +FROM ${PYTHON_IMAGE} AS base +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ffmpeg fontconfig fonts-dejavu-core fonts-liberation graphviz libcairo2 \ + libnl-route-3-200 libprotobuf32t64 \ + && rm -rf /var/lib/apt/lists/* \ + && fc-cache -f +COPY --from=nsjail-build /src/nsjail/nsjail /usr/sbin/nsjail +COPY --from=venv /opt/sandbox/venv /opt/sandbox/venv +# Matplotlib rebuilds its font list when its config dir is read-only, so the +# prebuilt copy here is copied into each session home. +RUN MPLCONFIGDIR=/opt/sandbox/matplotlib /opt/sandbox/venv/bin/python -c "import matplotlib.font_manager" +COPY sandbox/nsjail/python.cfg /etc/sandbox/python.cfg +EXPOSE 8090 +HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=6 \ + CMD python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/health', timeout=2)" +# The supervisor mounts session tmpfs homes and creates nsjail cgroups, so it +# runs as root; every script runs as uid 65534 inside nsjail. +USER root +ENTRYPOINT ["openplotva-sandbox"] + +FROM rust:${RUST_VERSION}-bookworm AS builder +WORKDIR /workspace +COPY Cargo.toml Cargo.lock ./ +COPY crates ./crates +COPY migrations ./migrations +COPY prompts ./prompts +COPY web ./web +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + --mount=type=cache,target=/workspace/target \ + cargo build --locked --release -p openplotva-sandbox \ + && cp /workspace/target/release/openplotva-sandbox /tmp/openplotva-sandbox + +FROM base AS runtime +COPY --from=builder /tmp/openplotva-sandbox /usr/local/bin/openplotva-sandbox + +FROM base AS runtime-prebuilt +COPY target/release/openplotva-sandbox /usr/local/bin/openplotva-sandbox diff --git a/sandbox/nsjail/python.cfg b/sandbox/nsjail/python.cfg new file mode 100644 index 00000000..6f714828 --- /dev/null +++ b/sandbox/nsjail/python.cfg @@ -0,0 +1,201 @@ +name: "python-sandbox" +description: "One Python call inside a sandbox session" + +mode: ONCE +hostname: "sandbox" +cwd: "/home" + +time_limit: 30 +max_cpus: 1 + +keep_env: false +envar: "LANG=C.UTF-8" +envar: "HOME=/home" +envar: "PATH=/opt/sandbox/venv/bin:/usr/bin" +envar: "PYTHONDONTWRITEBYTECODE=1" +envar: "PYTHONIOENCODING=utf-8" +envar: "PYTHONUNBUFFERED=1" +envar: "MPLBACKEND=Agg" +envar: "MPLCONFIGDIR=/home/.config/matplotlib" +envar: "XDG_CACHE_HOME=/home/.cache" +envar: "OMP_NUM_THREADS=1" +envar: "OPENBLAS_NUM_THREADS=1" +envar: "MKL_NUM_THREADS=1" +envar: "NUMEXPR_NUM_THREADS=1" +envar: "VECLIB_MAXIMUM_THREADS=1" + +keep_caps: false + +rlimit_as_type: INF +rlimit_cpu: 60 +rlimit_fsize: 100 +rlimit_nofile: 128 +rlimit_stack: 8 + +clone_newnet: true +clone_newuser: true +clone_newns: true +clone_newpid: true +clone_newipc: true +clone_newuts: true +clone_newcgroup: true +iface_no_lo: true + +uidmap { + inside_id: "65534" + outside_id: "65534" +} + +gidmap { + inside_id: "65534" + outside_id: "65534" +} + +mount_proc: false + +mount { + src: "/etc/ld.so.cache" + dst: "/etc/ld.so.cache" + is_bind: true + rw: false +} + +mount { + src: "/etc/alternatives" + dst: "/etc/alternatives" + is_bind: true + rw: false +} + +mount { + src: "/lib" + dst: "/lib" + is_bind: true + rw: false +} + +mount { + src: "/lib64" + dst: "/lib64" + is_bind: true + rw: false + mandatory: false +} + +mount { + src: "/usr/lib" + dst: "/usr/lib" + is_bind: true + rw: false +} + +mount { + src: "/usr/local/bin" + dst: "/usr/local/bin" + is_bind: true + rw: false +} + +mount { + src: "/usr/local/lib" + dst: "/usr/local/lib" + is_bind: true + rw: false +} + +mount { + src: "/opt/sandbox/venv" + dst: "/opt/sandbox/venv" + is_bind: true + rw: false +} + +mount { + src: "/usr/bin/dot" + dst: "/usr/bin/dot" + is_bind: true + rw: false +} + +mount { + src: "/usr/bin/ffmpeg" + dst: "/usr/bin/ffmpeg" + is_bind: true + rw: false +} + +mount { + src: "/usr/bin/ffprobe" + dst: "/usr/bin/ffprobe" + is_bind: true + rw: false +} + +mount { + src: "/usr/share/fonts" + dst: "/usr/share/fonts" + is_bind: true + rw: false +} + +mount { + src: "/etc/fonts" + dst: "/etc/fonts" + is_bind: true + rw: false +} + +mount { + src: "/var/cache/fontconfig" + dst: "/var/cache/fontconfig" + is_bind: true + rw: false +} + +mount { + src: "/dev/null" + dst: "/dev/null" + is_bind: true + rw: true +} + +mount { + src: "/dev/urandom" + dst: "/dev/urandom" + is_bind: true + rw: false +} + +mount { + dst: "/tmp" + fstype: "tmpfs" + rw: true + is_bind: false + options: "size=16m" +} + +mount { + dst: "/dev/shm" + fstype: "tmpfs" + rw: true + is_bind: false + options: "size=16m" +} + +cgroup_mem_max: 268435456 +cgroup_mem_swap_max: 0 +cgroup_pids_max: 16 +cgroup_cpu_ms_per_sec: 1000 +use_cgroupv2: true + +seccomp_string: "ERRNO(1) {" +seccomp_string: " ptrace, process_vm_readv, process_vm_writev," +seccomp_string: " mount, umount, pivot_root, chroot, setns, unshare," +seccomp_string: " fsopen, fsmount, move_mount, open_tree," +seccomp_string: " kexec_load, init_module, finit_module, delete_module," +seccomp_string: " reboot, swapon, swapoff, syslog, acct, quotactl," +seccomp_string: " keyctl, add_key, request_key, bpf, userfaultfd, perf_event_open," +seccomp_string: " io_uring_setup, io_uring_enter, io_uring_register," +seccomp_string: " open_by_handle_at, name_to_handle_at" +seccomp_string: "}" +seccomp_string: "DEFAULT ALLOW" diff --git a/sandbox/requirements.in b/sandbox/requirements.in new file mode 100644 index 00000000..c67bf39b --- /dev/null +++ b/sandbox/requirements.in @@ -0,0 +1,43 @@ +# Packages preinstalled in the Python sandbox image. +# Edit this file, then regenerate requirements.txt: +# uv pip compile sandbox/requirements.in --universal --python-version 3.14 -o sandbox/requirements.txt +# pyarrow is left out on purpose: pandas 3 does not need it, and its import +# alone takes a large part of the per-call memory limit. + +# Data and math +numpy +pandas +scipy +sympy +statsmodels +scikit-learn +networkx +shapely + +# Charts, diagrams and images +matplotlib +seaborn +pillow +wordcloud +qrcode +graphviz +cairosvg + +# Audio and video (calls the system ffmpeg binary) +ffmpeg-python + +# Reading and writing user files +openpyxl +xlrd +python-docx +pypdf +fpdf2 +lxml +beautifulsoup4 +pyyaml +charset-normalizer + +# Text output and dates +tabulate +python-dateutil +tzdata diff --git a/sandbox/requirements.txt b/sandbox/requirements.txt new file mode 100644 index 00000000..c602f440 --- /dev/null +++ b/sandbox/requirements.txt @@ -0,0 +1,165 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile sandbox/requirements.in --universal --python-version 3.14 -o sandbox/requirements.txt +beautifulsoup4==4.15.0 + # via -r sandbox/requirements.in +cairocffi==1.7.1 + # via cairosvg +cairosvg==2.9.1 + # via -r sandbox/requirements.in +cffi==2.1.1 + # via cairocffi +charset-normalizer==3.5.2 + # via -r sandbox/requirements.in +cloudpickle==3.1.2 + # via joblib +colorama==0.4.6 ; sys_platform == 'win32' + # via qrcode +contourpy==1.4.0 + # via matplotlib +cssselect2==0.10.1 + # via cairosvg +cycler==0.12.1 + # via matplotlib +defusedxml==0.7.1 + # via + # cairosvg + # fpdf2 +et-xmlfile==2.0.0 + # via openpyxl +ffmpeg-python==0.2.0 + # via -r sandbox/requirements.in +fonttools==4.67.0 + # via + # fpdf2 + # matplotlib +formulaic==1.2.2 + # via statsmodels +fpdf2==2.8.9 + # via -r sandbox/requirements.in +future==1.0.0 + # via ffmpeg-python +graphviz==0.21 + # via -r sandbox/requirements.in +interface-meta==2.0.1 + # via formulaic +joblib==1.6.0 + # via scikit-learn +kiwisolver==1.5.1 + # via matplotlib +lxml==6.1.3 + # via + # -r sandbox/requirements.in + # python-docx +matplotlib==3.11.2 + # via + # -r sandbox/requirements.in + # seaborn + # wordcloud +mpmath==1.3.0 + # via sympy +narwhals==2.27.1 + # via + # formulaic + # scikit-learn +networkx==3.7 + # via -r sandbox/requirements.in +numpy==2.5.4 + # via + # -r sandbox/requirements.in + # contourpy + # formulaic + # matplotlib + # pandas + # patsy + # scikit-learn + # scipy + # seaborn + # shapely + # statsmodels + # wordcloud +openpyxl==3.1.5 + # via -r sandbox/requirements.in +packaging==26.3 + # via + # matplotlib + # patsy + # statsmodels +pandas==3.0.6 + # via + # -r sandbox/requirements.in + # formulaic + # seaborn + # statsmodels +patsy==1.0.3 + # via statsmodels +pillow==13.0.0 + # via + # -r sandbox/requirements.in + # cairosvg + # fpdf2 + # matplotlib + # wordcloud +pycparser==3.11 ; implementation_name != 'PyPy' + # via cffi +pyparsing==3.3.3 + # via matplotlib +pypdf==6.21.1 + # via -r sandbox/requirements.in +python-dateutil==2.9.0.post0 + # via + # -r sandbox/requirements.in + # matplotlib + # pandas +python-docx==1.2.0 + # via -r sandbox/requirements.in +pyyaml==6.0.3 + # via -r sandbox/requirements.in +qrcode==8.2 + # via -r sandbox/requirements.in +scikit-learn==1.9.1 + # via -r sandbox/requirements.in +scipy==1.18.1 + # via + # -r sandbox/requirements.in + # formulaic + # scikit-learn + # statsmodels +seaborn==0.13.2 + # via -r sandbox/requirements.in +shapely==2.2.0 + # via -r sandbox/requirements.in +six==1.17.0 + # via python-dateutil +soupsieve==3.0.3 + # via beautifulsoup4 +statsmodels==0.15.0 + # via -r sandbox/requirements.in +sympy==1.14.0 + # via -r sandbox/requirements.in +tabulate==0.10.0 + # via -r sandbox/requirements.in +threadpoolctl==3.7.0 + # via scikit-learn +tinycss2==1.5.1 + # via + # cairosvg + # cssselect2 +typing-extensions==4.16.0 + # via + # beautifulsoup4 + # formulaic + # python-docx +tzdata==2026.5 + # via + # -r sandbox/requirements.in + # pandas +webencodings==0.6.1 + # via + # cssselect2 + # tinycss2 +wordcloud==1.9.6 + # via -r sandbox/requirements.in +wrapt==2.5.1 + # via formulaic +xlrd==2.0.2 + # via -r sandbox/requirements.in diff --git a/tools/maintenance/tests/test_deploy.py b/tools/maintenance/tests/test_deploy.py index 91dc2596..9dc60ed6 100644 --- a/tools/maintenance/tests/test_deploy.py +++ b/tools/maintenance/tests/test_deploy.py @@ -80,6 +80,7 @@ def run_configure(self, *, overlay_present, existing_enabled=False): "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"], "OPENPLOTVA_DEPLOY_ROOT": str(deploy_root), "OPENPLOTVA_DEPLOY_IMAGE": "ghcr.io/example/openplotva:synthetic", + "OPENPLOTVA_SANDBOX_IMAGE": "ghcr.io/example/openplotva-sandbox:synthetic", "OPENPLOTVA_MAINTENANCE_ENV_FILE": str(runtime), } command = [