diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ab4a91b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +target/ +node_modules/ +.dist/ +.env +logs/ +models/ +.DS_Store +.idea/ +.vscode/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0d9fb65 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[workspace] +members = [ + "apps/api", + "apps/auth", + "sandbox" +] +resolver = "2" + +[workspace.package] +edition = "2021" diff --git a/README.md b/README.md index ca184c9..ce2fefd 100644 --- a/README.md +++ b/README.md @@ -1 +1,7 @@ -# coder \ No newline at end of file +# CyberDevStudio + +CyberDevStudio is an ambitious, modular developer platform that combines a Rust backend, a TypeScript front-end, and an embedded node-llama-cpp inference service. This repository currently provides the project scaffold, documentation, and configuration needed to begin implementing the full system described in the high-level specification. + +The workspace is organized to support multiple services (API, Auth, LLM server, Studio UI) as well as shared components such as sandboxed execution, metrics, schemas, and database migrations. Each module is intended to be developed independently while sharing a coherent deployment story through Docker Compose. + +For architectural guidance, planned milestones, and module-specific expectations, see [`docs/Projektplan.md`](docs/Projektplan.md). diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml new file mode 100644 index 0000000..58409e8 --- /dev/null +++ b/apps/api/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "cyberdev_api" +version = "0.0.1" +edition = "2021" + +[dependencies] +axum = { version = "0.7", features = ["macros"] } +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +base64 = "0.21" +cyberdev_sandbox = { path = "../../sandbox" } + +[dev-dependencies] +tower = { version = "0.4", features = ["util"] } +tempfile = "3" diff --git a/apps/api/README.md b/apps/api/README.md new file mode 100644 index 0000000..17ca385 --- /dev/null +++ b/apps/api/README.md @@ -0,0 +1,16 @@ +# API Gateway (Rust) + +Dieses Verzeichnis beherbergt den JSON-RPC Gateway Service. Aktueller Stand: + +- Axum-basierter HTTP Endpunkt `/rpc` für die Methoden `fs.write` und `run.exec`. +- Einfache Fehlerabbildung auf JSON-RPC Codes (`-3260x`, `-3201x`). +- Weiterleitung an das Sandbox-Crate für Dateisystem- und Prozessoperationen. +- Healthcheck unter `/health`. +- Umfangreiche Unit-/Integrationstests mit temporären Workspaces. + +Geplante nächste Schritte: + +- Verbindung zur PostgreSQL/PostgresML Datenbank. +- RPC Dispatch, Policy Checks, Token-Billing Hooks. +- Integration mit OpenTelemetry und Rate-Limiting. +- WebSocket Routen für Streaming-Ausgaben. diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs new file mode 100644 index 0000000..05bd033 --- /dev/null +++ b/apps/api/src/main.rs @@ -0,0 +1,417 @@ +use std::net::SocketAddr; +use std::path::PathBuf; +use std::time::Duration; + +use axum::body::to_bytes; +use axum::body::Body; +use axum::http::Request; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use base64::Engine; +use cyberdev_sandbox::{ + execute_with_config, write_file, ExecuteConfig, ExecutionResult, FsError, RunError, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tracing::{error, info, Level}; +use tracing_subscriber::FmtSubscriber; + +fn app_router() -> Router { + Router::new() + .route("/health", get(health)) + .route("/rpc", post(handle_rpc)) +} + +#[tokio::main] +async fn main() { + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::INFO) + .with_env_filter("info") + .finish(); + + tracing::subscriber::set_global_default(subscriber).expect("setting tracing subscriber failed"); + + let app = app_router(); + + let port: u16 = std::env::var("RPC_PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(6813); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + + info!("Starting API gateway", %port); + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await + .expect("server crashed"); +} + +async fn health() -> &'static str { + "ok" +} + +async fn handle_rpc(Json(request): Json) -> Json { + let id = request.id.clone(); + match dispatch(request) { + Ok(result) => Json(RpcResponse::success(id, result)), + Err(err) => { + error!("rpc_error" = %err.message, code = err.code, data = ?err.data); + Json(RpcResponse::error(id, err)) + } + } +} + +fn dispatch(request: RpcRequest) -> Result { + match request.method.as_str() { + "fs.write" => handle_fs_write(request.params), + "run.exec" => handle_run_exec(request.params), + other => Err(RpcErrorResponse::method_not_found(other)), + } +} + +fn handle_fs_write(params: Value) -> Result { + let params: FsWriteParams = serde_json::from_value(params) + .map_err(|err| RpcErrorResponse::invalid_params(err.to_string()))?; + + let data = match params.encoding { + Encoding::Utf8 => params.contents.into_bytes(), + Encoding::Base64 => base64::engine::general_purpose::STANDARD + .decode(¶ms.contents) + .map_err(|err| { + RpcErrorResponse::invalid_params(format!("invalid base64 contents: {err}")) + })?, + }; + + let bytes = data.len(); + write_file(¶ms.path, &data).map_err(RpcErrorResponse::from_fs_error)?; + + Ok(json!({ + "path": params.path, + "bytes": bytes, + })) +} + +fn handle_run_exec(params: Value) -> Result { + let params: RunExecParams = serde_json::from_value(params) + .map_err(|err| RpcErrorResponse::invalid_params(err.to_string()))?; + + let mut config = ExecuteConfig::default(); + config.timeout = params.timeout_ms.map(|ms| Duration::from_millis(ms as u64)); + if let Some(dir) = params.working_directory { + config.working_directory = Some(PathBuf::from(dir)); + } + + let result = execute_with_config(¶ms.command, ¶ms.args, config) + .map_err(RpcErrorResponse::from_run_error)?; + + Ok(serialize_execution_result(&result)) +} + +fn serialize_execution_result(result: &ExecutionResult) -> Value { + let exit_code = result.status.code(); + json!({ + "status": { + "success": result.status.success(), + "code": exit_code, + }, + "stdout": result.stdout, + "stderr": result.stderr, + "duration_ms": result.duration.as_millis() as u64, + }) +} + +#[derive(Debug, Deserialize)] +struct RpcRequest { + #[serde(default)] + jsonrpc: Option, + method: String, + #[serde(default)] + params: Value, + #[serde(default)] + id: Option, +} + +#[derive(Debug, Deserialize)] +struct FsWriteParams { + path: String, + contents: String, + #[serde(default)] + encoding: Encoding, +} + +#[derive(Debug, Deserialize)] +struct RunExecParams { + command: String, + #[serde(default)] + args: Vec, + #[serde(default)] + timeout_ms: Option, + #[serde(default)] + working_directory: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "lowercase")] +enum Encoding { + Utf8, + Base64, +} + +impl Default for Encoding { + fn default() -> Self { + Encoding::Utf8 + } +} + +#[derive(Debug, Serialize)] +struct RpcResponse { + jsonrpc: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(flatten)] + payload: RpcPayload, +} + +impl RpcResponse { + fn success(id: Option, result: Value) -> Self { + Self { + jsonrpc: "2.0", + id, + payload: RpcPayload::Result { result }, + } + } + + fn error(id: Option, err: RpcErrorResponse) -> Self { + Self { + jsonrpc: "2.0", + id, + payload: RpcPayload::Error { + error: RpcErrorObject::from(err), + }, + } + } +} + +#[derive(Debug, Serialize)] +#[serde(untagged)] +enum RpcPayload { + Result { result: Value }, + Error { error: RpcErrorObject }, +} + +#[derive(Debug, Serialize)] +struct RpcErrorObject { + code: i64, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, +} + +#[derive(Debug)] +struct RpcErrorResponse { + code: i64, + message: String, + data: Option, +} + +impl RpcErrorResponse { + fn method_not_found(method: &str) -> Self { + Self { + code: -32601, + message: format!("method `{method}` not found"), + data: None, + } + } + + fn invalid_params(message: String) -> Self { + Self { + code: -32602, + message, + data: None, + } + } + + fn server_error(message: impl Into, data: Option) -> Self { + Self { + code: -32000, + message: message.into(), + data, + } + } + + fn from_fs_error(error: FsError) -> Self { + match error { + FsError::AbsolutePath(path) => Self::invalid_params(format!( + "absolute paths are not permitted: {}", + path.display() + )), + FsError::TraversalAttempt => { + Self::invalid_params("path traversal outside the workspace is not allowed".into()) + } + FsError::FileTooLarge { size, limit } => { + Self::invalid_params(format!("file size {size} exceeds limit of {limit} bytes")) + } + other => Self::server_error( + "filesystem operation failed", + Some(json!({ + "kind": "FsError", + "details": other.to_string(), + })), + ), + } + } + + fn from_run_error(error: RunError) -> Self { + match error { + RunError::CommandNotAllowed(command) => Self { + code: -32010, + message: format!("command `{command}` is not permitted"), + data: Some(json!({ "command": command })), + }, + RunError::Timeout(duration) => Self { + code: -32011, + message: "command timed out".into(), + data: Some(json!({ "timeout_ms": duration.as_millis() })), + }, + RunError::OutputLimit { stream, limit } => Self { + code: -32012, + message: format!("{stream} output exceeded limit"), + data: Some(json!({ "stream": stream.to_string(), "limit": limit })), + }, + RunError::Workspace(error) => Self::from_fs_error(error), + RunError::MissingStream { stream } => { + Self::server_error(format!("child process missing {stream} stream"), None) + } + RunError::ReaderThread => Self::server_error("output reader thread failed", None), + RunError::Io(error) => Self::server_error( + "io error while executing command", + Some(json!({ "details": error.to_string() })), + ), + } + } +} + +impl From for RpcErrorObject { + fn from(value: RpcErrorResponse) -> Self { + Self { + code: value.code, + message: value.message, + data: value.data, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use axum::http::StatusCode; + use serde_json::json; + use tower::ServiceExt; + + use cyberdev_sandbox::{read_file, WORKSPACE_ROOT_ENV}; + + async fn send_rpc(router: Router, payload: Value) -> (StatusCode, Value) { + let request = Request::post("/rpc") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&payload).expect("serialize payload"), + )) + .expect("request"); + + let response = router.oneshot(request).await.expect("router response"); + + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("read body"); + let value: Value = serde_json::from_slice(&body).expect("decode json"); + (status, value) + } + + #[tokio::test] + async fn fs_write_roundtrip() { + let temp_dir = tempfile::tempdir().expect("create workspace"); + std::env::set_var(WORKSPACE_ROOT_ENV, temp_dir.path()); + + let (status, body) = send_rpc( + app_router(), + json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "fs.write", + "params": { + "path": "hello.txt", + "contents": "cyberdev", + } + }), + ) + .await; + + std::env::remove_var(WORKSPACE_ROOT_ENV); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["result"]["path"], "hello.txt"); + assert_eq!(body["result"]["bytes"], 8); + + std::env::set_var(WORKSPACE_ROOT_ENV, temp_dir.path()); + let contents = read_file("hello.txt").expect("read file"); + assert_eq!(contents, "cyberdev"); + std::env::remove_var(WORKSPACE_ROOT_ENV); + } + + #[tokio::test] + async fn run_exec_invokes_command() { + let temp_dir = tempfile::tempdir().expect("create workspace"); + std::env::set_var(WORKSPACE_ROOT_ENV, temp_dir.path()); + + let (status, body) = send_rpc( + app_router(), + json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "run.exec", + "params": { + "command": "sh", + "args": ["-c", "printf done"], + } + }), + ) + .await; + + std::env::remove_var(WORKSPACE_ROOT_ENV); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["result"]["status"]["success"], true); + assert_eq!(body["result"]["stdout"], "done"); + assert_eq!(body["result"]["stderr"], ""); + } + + #[tokio::test] + async fn run_exec_blocks_disallowed_commands() { + let temp_dir = tempfile::tempdir().expect("create workspace"); + std::env::set_var(WORKSPACE_ROOT_ENV, temp_dir.path()); + + let (status, body) = send_rpc( + app_router(), + json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "run.exec", + "params": { + "command": "rm" + } + }), + ) + .await; + + std::env::remove_var(WORKSPACE_ROOT_ENV); + + assert_eq!(status, StatusCode::OK); + assert_eq!(body["error"]["code"], -32010); + assert!(body["error"]["message"] + .as_str() + .unwrap() + .contains("not permitted")); + } +} diff --git a/apps/auth/Cargo.toml b/apps/auth/Cargo.toml new file mode 100644 index 0000000..3175ffd --- /dev/null +++ b/apps/auth/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cyberdev_auth" +version = "0.0.1" +edition = "2021" + +[dependencies] +axum = { version = "0.7", features = ["macros"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/apps/auth/README.md b/apps/auth/README.md new file mode 100644 index 0000000..cf47754 --- /dev/null +++ b/apps/auth/README.md @@ -0,0 +1,8 @@ +# Auth Service (Rust) + +Aufgaben des Auth-Services: + +- Benutzerregistrierung und Rollenverwaltung. +- Passwort-Hashing (Argon2) und JWT-Ausstellung. +- API-Key Verwaltung mit Audit-Logs. +- Token-Balance Synchronisation mit PostgresML. diff --git a/apps/auth/src/main.rs b/apps/auth/src/main.rs new file mode 100644 index 0000000..d4edaa1 --- /dev/null +++ b/apps/auth/src/main.rs @@ -0,0 +1,45 @@ +use axum::{routing::post, Json, Router}; +use serde::Deserialize; +use std::net::SocketAddr; +use tracing::{info, Level}; +use tracing_subscriber::FmtSubscriber; + +type AppResult = Result, axum::http::StatusCode>; + +#[derive(Deserialize)] +struct LoginRequest { + username: String, + password: String, +} + +#[tokio::main] +async fn main() { + let subscriber = FmtSubscriber::builder() + .with_max_level(Level::INFO) + .with_env_filter("info") + .finish(); + + tracing::subscriber::set_global_default(subscriber).expect("setting tracing subscriber failed"); + + let app = Router::new().route("/login", post(login)); + + let port: u16 = std::env::var("AUTH_PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(6971); + + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + info!("Starting placeholder Auth service", %port); + + axum::Server::bind(&addr) + .serve(app.into_make_service()) + .await + .expect("auth server crashed"); +} + +async fn login(Json(_payload): Json) -> AppResult { + Ok(Json(serde_json::json!({ + "token": "placeholder", + "role": "developer" + }))) +} diff --git a/apps/llmserver/README.md b/apps/llmserver/README.md new file mode 100644 index 0000000..f7199cf --- /dev/null +++ b/apps/llmserver/README.md @@ -0,0 +1,8 @@ +# LLM Server (node-llama-cpp) + +Der LLM-Server kapselt node-llama-cpp und bietet Token-kontrollierte Endpunkte: + +- OpenAI-kompatible `/v1/*` APIs. +- Admin-Endpunkte zum Laden/Entladen von Modellen. +- Tokenverbrauch per Middleware + Redis Cache. +- Prometheus-kompatibles Metrics-Endpoint. diff --git a/apps/llmserver/package.json b/apps/llmserver/package.json new file mode 100644 index 0000000..879eefd --- /dev/null +++ b/apps/llmserver/package.json @@ -0,0 +1,13 @@ +{ + "name": "llmserver", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "start": "node ./src/index.mjs", + "dev": "node --watch ./src/index.mjs" + }, + "dependencies": { + "node-llama-cpp": "^2.7.5" + } +} diff --git a/apps/llmserver/src/index.mjs b/apps/llmserver/src/index.mjs new file mode 100644 index 0000000..d6652d1 --- /dev/null +++ b/apps/llmserver/src/index.mjs @@ -0,0 +1,11 @@ +// Placeholder entrypoint for the node-llama-cpp wrapper service. +// Implements configuration scaffolding and logs startup intentions. + +import process from 'node:process'; + +function main() { + console.log('[llmserver] Starting placeholder service on port', process.env.LLM_PORT ?? '6988'); + console.log('[llmserver] TODO: initialize node-llama-cpp bindings and token metering.'); +} + +main(); diff --git a/apps/studio-ui/README.md b/apps/studio-ui/README.md new file mode 100644 index 0000000..4b79acb --- /dev/null +++ b/apps/studio-ui/README.md @@ -0,0 +1,8 @@ +# Studio UI (TypeScript) + +Geplante Funktionalitäten: + +- Monaco IDE mit Projektbaum und Multi-Panel Layout. +- Agent Chat mit Streaming Output. +- Admin Dashboard inkl. Modellverwaltung und Nutzerübersicht. +- Metrics Overlay (Prometheus/OTEL) im NeonCyberNight Theme. diff --git a/apps/studio-ui/index.html b/apps/studio-ui/index.html new file mode 100644 index 0000000..5372912 --- /dev/null +++ b/apps/studio-ui/index.html @@ -0,0 +1,12 @@ + + + + + + CyberDevStudio + + +
+ + + diff --git a/apps/studio-ui/package.json b/apps/studio-ui/package.json new file mode 100644 index 0000000..4aadf8b --- /dev/null +++ b/apps/studio-ui/package.json @@ -0,0 +1,21 @@ +{ + "name": "studio-ui", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint src --ext .ts,.tsx" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.2.0", + "typescript": "^5.4.0", + "vite": "^5.1.0" + } +} diff --git a/apps/studio-ui/src/main.tsx b/apps/studio-ui/src/main.tsx new file mode 100644 index 0000000..719397e --- /dev/null +++ b/apps/studio-ui/src/main.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +const App: React.FC = () => { + return ( +
+
+

+ CyberDevStudio +

+

+ Placeholder UI shell – Monaco IDE, Agent Chat und Admin Dashboard folgen. +

+
+
+
+

Agent Console

+

+ Streaming Chat, Toolaufrufe und Tokenverbrauch werden hier dargestellt. +

+
+
+

Sandbox Status

+

+ Prozessausführungen, FS-Operationen und Logs erscheinen in diesem Panel. +

+
+
+
+ ); +}; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + +); diff --git a/apps/studio-ui/tsconfig.json b/apps/studio-ui/tsconfig.json new file mode 100644 index 0000000..b6ffe55 --- /dev/null +++ b/apps/studio-ui/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2020"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "esModuleInterop": true, + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/apps/studio-ui/vite.config.ts b/apps/studio-ui/vite.config.ts new file mode 100644 index 0000000..cc79326 --- /dev/null +++ b/apps/studio-ui/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { + host: '0.0.0.0', + port: Number(process.env.UI_PORT ?? 6711) + } +}); diff --git a/database/migrations/0001_init.sql b/database/migrations/0001_init.sql new file mode 100644 index 0000000..b031260 --- /dev/null +++ b/database/migrations/0001_init.sql @@ -0,0 +1,32 @@ +-- Initial migration placeholder for CyberDevStudio. +-- Creates core tables for users, models, and token usage tracking. + +CREATE EXTENSION IF NOT EXISTS vector; +CREATE EXTENSION IF NOT EXISTS pgml; + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + role TEXT NOT NULL CHECK (role IN ('admin', 'developer', 'viewer')), + api_key_hash TEXT NOT NULL, + balance BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS models ( + id UUID PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + context_size INTEGER NOT NULL, + cost_per_token NUMERIC(10,6) NOT NULL, + source_url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tokens_used ( + id BIGSERIAL PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + model_id UUID REFERENCES models(id) ON DELETE SET NULL, + tokens INTEGER NOT NULL, + metadata JSONB DEFAULT '{}'::JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); diff --git a/docker/Dockerfile.api b/docker/Dockerfile.api new file mode 100644 index 0000000..b74c7f1 --- /dev/null +++ b/docker/Dockerfile.api @@ -0,0 +1,10 @@ +FROM rust:1.76-slim AS builder +WORKDIR /app +COPY ../.. . +# TODO: implement workspace build steps once crates exist + +FROM debian:bookworm-slim +WORKDIR /app +COPY --from=builder /app/target/release/api /usr/local/bin/api +EXPOSE 6813 +CMD ["/usr/local/bin/api"] diff --git a/docker/Dockerfile.auth b/docker/Dockerfile.auth new file mode 100644 index 0000000..ddd2558 --- /dev/null +++ b/docker/Dockerfile.auth @@ -0,0 +1,10 @@ +FROM rust:1.76-slim AS builder +WORKDIR /app +COPY ../.. . +# TODO: compile auth service once crate is available + +FROM debian:bookworm-slim +WORKDIR /app +COPY --from=builder /app/target/release/auth /usr/local/bin/auth +EXPOSE 6971 +CMD ["/usr/local/bin/auth"] diff --git a/docker/Dockerfile.llm b/docker/Dockerfile.llm new file mode 100644 index 0000000..a5067eb --- /dev/null +++ b/docker/Dockerfile.llm @@ -0,0 +1,7 @@ +FROM node:20-slim +WORKDIR /srv/llm +COPY ../.. . +RUN npm install --global pnpm && \ + pnpm install --filter llmserver... +EXPOSE 6988 +CMD ["pnpm", "--filter", "llmserver", "start"] diff --git a/docker/Dockerfile.ui b/docker/Dockerfile.ui new file mode 100644 index 0000000..17ac638 --- /dev/null +++ b/docker/Dockerfile.ui @@ -0,0 +1,7 @@ +FROM node:20-slim +WORKDIR /srv/ui +COPY ../.. . +RUN npm install --global pnpm && \ + pnpm install --filter studio-ui... +EXPOSE 6711 +CMD ["pnpm", "--filter", "studio-ui", "dev", "--", "--host", "0.0.0.0", "--port", "6711"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..8c24830 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,47 @@ +version: "3.9" + +services: + api: + build: ./docker/Dockerfile.api + ports: + - "6813:6813" + environment: + - RPC_PORT=6813 + depends_on: + - db + - llmserver + + llmserver: + build: ./docker/Dockerfile.llm + ports: + - "6988:6988" + environment: + - LLM_PORT=6988 + volumes: + - ../models:/models + - ../logs:/logs + + studio-ui: + build: ./docker/Dockerfile.ui + ports: + - "6711:6711" + + auth: + build: ./docker/Dockerfile.auth + ports: + - "6971:6971" + + db: + image: postgresml/postgresml + restart: always + environment: + - POSTGRES_USER=admin + - POSTGRES_PASSWORD=supersecure + - POSTGRES_DB=cyberstudio + ports: + - "6472:5432" + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..51dce83 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,76 @@ +# CyberDevStudio API Übersicht + +Die detaillierte API-Spezifikation folgt dem JSON-RPC-Ansatz für Entwicklungsoperationen und stellt RESTful Endpunkte für Authentifizierung sowie LLM-Steuerung bereit. Dieses Dokument beschreibt die Zielstruktur und dient als Grundlage für die spätere Spezifikation. + +## JSON-RPC Namespaces + +- `project.*` – Projekt- und Dateiverwaltung. +- `sandbox.*` – Ausführung und Ressourcenmanagement. +- `agent.*` – LLM-Agenten, Prompt-Pipelines und Tools. +- `admin.*` – Admin-spezifische Operationen (z.B. Token-Adjustments). + +Jede Methode erhält eine JSON-Schema Definition in `schemas/rpc` und wird über das Gateway (`apps/api`) bereitgestellt. + +## Authentifizierung + +- Benutzer melden sich mit Username/Passwort im Auth-Service an. +- JWT Tokens sichern API-Aufrufe; API-Keys für Dienst-zu-Dienst Verkehr. +- LLM-Aufrufe benötigen Header `X-Cyber-Token` für Budgetnachweis. + +## LLM Server Endpunkte (node-llama-cpp) + +| Methode | Pfad | Beschreibung | +| ------- | ---- | ------------ | +| `POST` | `/v1/chat/completions` | Chat-Completion API (OpenAI-kompatibel) | +| `POST` | `/v1/completions` | Prompt Completion | +| `POST` | `/v1/embeddings` | Embedding Berechnung | +| `POST` | `/admin/load` | Modell aus `/models` laden | +| `POST` | `/admin/unload` | Aktives Modell entladen | +| `GET` | `/admin/status` | Systemstatus (RAM, Tokens, Threads) | +| `GET` | `/admin/models` | Verfügbare GGUF-Modelle | +| `GET` | `/metrics` | Prometheus-kompatible Metriken | + +## WebSocket Streams + +- `wss://api:6813/agent/chat` – Streaming Antworten der Agenten. +- `wss://api:6813/sandbox/logs` – Live-Logs von Ausführungen. +- `wss://api:6813/admin/events` – Modell- und User-Events. + +## Roadmap + +1. Definition der JSON-Schemas für Kernaktionen. +2. Implementierung der Auth-Middleware mit JWT + API-Key Prüfung. +3. Aufbau des Telemetrie-Pipelines (OTEL + Prometheus). +4. Dokumentation der Fehlercodes und Ratenlimits. + +## Implementierte JSON-RPC Methoden + +### `fs.write` +- **Route:** `POST /rpc` +- **Beschreibung:** Schreibt Dateien relativ zum Workspace (UTF-8 oder Base64 Inhalt). +- **Antwort:** `{ "result": { "path": "", "bytes": } }` +- **Fehlercodes:** + - `-32602` – Ungültige Parameter (z.B. absolute Pfade, Traversal, zu große Dateien). + - `-32000` – Interner Dateisystemfehler. + +### `run.exec` +- **Route:** `POST /rpc` +- **Beschreibung:** Führt zugelassene Kommandos innerhalb des Sandbox-Workspaces aus. +- **Antwort:** + ```json + { + "result": { + "status": { "success": true, "code": 0 }, + "stdout": "…", + "stderr": "…", + "duration_ms": 42 + } + } + ``` +- **Fehlercodes:** + - `-32010` – Kommando nicht erlaubt. + - `-32011` – Timeout überschritten. + - `-32012` – Ausgabebegrenzung überschritten. + - `-32000` – Interner Ausführungsfehler (I/O, fehlende Streams, etc.). + +Weitere Methoden folgen in späteren Iterationen. diff --git a/docs/Projektplan.md b/docs/Projektplan.md new file mode 100644 index 0000000..063a282 --- /dev/null +++ b/docs/Projektplan.md @@ -0,0 +1,108 @@ +# CyberDevStudio – Projektplan + +## Überblick + +CyberDevStudio vereint einen Rust-basierten Ausführungs- und API-Stack mit einem TypeScript-Frontend, einer PostgresML-Datenbank und einem eingebetteten node-llama-cpp Server. Die Plattform stellt Agenten-gestützte Entwicklungswerkzeuge, Telemetrie, Modellverwaltung sowie Token-basierte Nutzungsabrechnung zur Verfügung. + +Dieses Dokument skizziert die Implementierungsphasen, Modulverantwortlichkeiten und technischen Kernentscheidungen. Es dient als lebender Plan für die Umsetzung und wird fortlaufend erweitert. + +## Architektur-Module + +| Modul | Beschreibung | Hauptechnologien | +| ----- | ------------ | ---------------- | +| `apps/api` | JSON-RPC Gateway, Projekt- und Dateiverwaltung, Sandbox-Orchestrierung, Telemetrieexport | Rust (Axum), OpenTelemetry, PostgreSQL, Tokio | +| `apps/auth` | Benutzerverwaltung, JWT/IAM, API-Key-Ausgabe, Token-Billing | Rust (Axum), PostgresML, Argon2 | +| `apps/llmserver` | node-llama-cpp Wrapper mit Tokenmetering, Modelldownload, Prozesskontrolle | Node.js, TypeScript, node-llama-cpp, Redis (Token Cache) | +| `apps/studio-ui` | Web-Frontend mit Monaco IDE, Agent Chat, Admin Dashboard, Metrics Panels | TypeScript, React, Vite, Tailwind, WebSockets | +| `sandbox` | Isolierte Ausführungsumgebungen (Dateisystem, Wasm, MicroVM, Prozesslauf) | Rust, Wasmtime, Firecracker (später), Seccomp | +| `schemas/rpc` | JSON-RPC Schemas für IDE- und Agent-Interaktionen | JSON Schema | +| `database` | Migrations für Benutzer, Modelle, Tokenlogs und PostgresML Erweiterungen | SQL, pgx | +| `metrics` | OpenTelemetry Konfiguration, Prometheus Scrapes, Alerting Hooks | YAML, Prometheus | +| `tests` | Unit- und E2E-Tests (Rust Integration + UI Smoke) | Rust (cargo nextest), Playwright | + +## Meilensteine + +1. **Grundgerüst & Infrastruktur** + - Docker-Compose mit allen Diensten auf nicht-standard Ports. + - PostgresML Konfiguration und initiale Tabellen. + - Basis-Rust-Workspace und Node/Vite-Setup. + +2. **Authentifizierung & Token-System** + - JWT-basierte Session Tokens. + - API-Key Middleware für RPC und LLM-Aufrufe. + - Token-Billing in `tokens_used` und Limits im Middleware-Layer. + +3. **Sandbox & Ausführung** + - Dateisystemoperationen (`fs.write`, `fs.read`). + - Prozessausführung (`run.exec`) mit Ressourcenlimits. + - Wasm-Runner (Wasmtime) und Micro-VM Skizze. + +4. **LLM-Integration** + - node-llama-cpp Einbindung mit Admin-Endpunkten. + - Modellverwaltungs-UI im Admin Panel. + - Tokenmetering + Kontextgrößen-Validierung. + +5. **UI & Agenten** + - Monaco IDE + Dateibaum. + - Agent Chat mit Streaming. + - Admin Dashboard mit Telemetrie und Nutzerverwaltung. + - NeonCyberNight & SerialSteel Themes. + +6. **Observability & Tests** + - OpenTelemetry/Prometheus Pipeline. + - CI-Pipeline: Format, Lint, Tests, Artefakte. + - E2E Tests für kritische RPC Pfade. + +## Datenbankentwurf + +```sql +CREATE TABLE users ( + id UUID PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + role TEXT NOT NULL CHECK (role IN ('admin', 'developer', 'viewer')), + api_key_hash TEXT NOT NULL, + balance BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE models ( + id UUID PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + context_size INTEGER NOT NULL, + cost_per_token NUMERIC(10,6) NOT NULL, + source_url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE tokens_used ( + id BIGSERIAL PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + model_id UUID REFERENCES models(id) ON DELETE SET NULL, + tokens INTEGER NOT NULL, + metadata JSONB DEFAULT '{}'::JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +## Tokenabrechnung + +- Jeder LLM-Aufruf prüft Benutzerrolle und verfügbares Tokenbudget. +- API-Key Header (`X-Cyber-Token`) identifiziert Nutzer im LLM-Service. +- Middleware im LLM-Server validiert Budget gegen Redis Cache + Postgres. +- Tokenlogs werden asynchron via Kafka-Thema `llm.usage` zur Datenbank repliziert. + +## Sicherheit & Compliance + +- Isolierte Netzwerke zwischen Sandbox und API über Docker Compose. +- Seccomp-Profile für Sandbox-Container. +- Audit-Logs für Admin-Aktionen (Modelle laden, Nutzerrollen ändern). +- TLS-Termination durch vorgeschalteten Proxy (nicht Teil dieses Repos). + +## Nächste Schritte + +1. Workspace `Cargo.toml` und Basis-Crates (`api`, `auth`, `sandbox`). +2. TypeScript Monorepo Setup mit PNPM Workspace (`studio-ui`, `llmserver`). +3. Implementierung der ersten Migrations (`users`, `models`). +4. Ausarbeitung der RPC-Schemas (`project.open`, `fs.write`, `run.exec`). +5. Skeleton-Tests und CI-Konfiguration. + diff --git a/docs/acceptance.md b/docs/acceptance.md new file mode 100644 index 0000000..a218bb2 --- /dev/null +++ b/docs/acceptance.md @@ -0,0 +1,20 @@ +# Akzeptanzkriterien – CyberDevStudio + +Diese Datei sammelt die akzeptanzrelevanten Prüfpunkte für CyberDevStudio. Die Kriterien werden pro Release-Zyklus erweitert. + +## Kernfunktionen +- Benutzer können sich mit JWT anmelden und erhalten rollenspezifische Rechte. +- Projekte lassen sich im Studio anlegen, Dateien bearbeiten und in der Sandbox ausführen. +- LLM-Aufrufe werden über node-llama-cpp abgewickelt und auf Tokenverbrauch geprüft. +- Admins können Modelle laden/entladen und Nutzerbudgets verwalten. +- Telemetrie ist über `/metrics` sowie das Admin-Dashboard abrufbar. + +## Tests +- Unit-Tests decken Sandbox-, Auth- und RPC-Module ab. +- End-to-End Tests prüfen `fs.write`, `run.exec` und `llm.chat`. +- Fehlerpfade (401, 403, 429, 500) werden simuliert und protokolliert. + +## Nichtfunktionale Anforderungen +- Alle Services laufen auf nicht-standard Ports (siehe Docker Compose). +- Ressourcenlimits: CPU/Memory für Sandbox, Tokenlimits für Modelle. +- Sicherheit: API-Keys, TLS-Termination, Audit Logging. diff --git a/examples/rpc/fs_write.json b/examples/rpc/fs_write.json new file mode 100644 index 0000000..5a7d030 --- /dev/null +++ b/examples/rpc/fs_write.json @@ -0,0 +1,10 @@ +{ + "jsonrpc": "2.0", + "id": "example-fs-write", + "method": "fs.write", + "params": { + "path": "README.md", + "contents": "Hello CyberDevStudio", + "encoding": "utf8" + } +} diff --git a/examples/rpc/llm_chat.json b/examples/rpc/llm_chat.json new file mode 100644 index 0000000..9716eec --- /dev/null +++ b/examples/rpc/llm_chat.json @@ -0,0 +1,14 @@ +{ + "jsonrpc": "2.0", + "id": "example-llm-chat", + "method": "llm.chat", + "params": { + "model": "deepseek-coder-1.3b", + "messages": [ + { "role": "system", "content": "You are CyberDevStudio." }, + { "role": "user", "content": "Say hello." } + ], + "temperature": 0.2, + "stream": true + } +} diff --git a/examples/rpc/run_exec.json b/examples/rpc/run_exec.json new file mode 100644 index 0000000..b5891c9 --- /dev/null +++ b/examples/rpc/run_exec.json @@ -0,0 +1,10 @@ +{ + "jsonrpc": "2.0", + "id": "example-run-exec", + "method": "run.exec", + "params": { + "command": "echo", + "args": ["hello", "cyber"], + "timeout_ms": 2000 + } +} diff --git a/metrics/otel-config.yaml b/metrics/otel-config.yaml new file mode 100644 index 0000000..b45a479 --- /dev/null +++ b/metrics/otel-config.yaml @@ -0,0 +1,28 @@ +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:55681 + grpc: + endpoint: 0.0.0.0:55680 + +exporters: + prometheus: + endpoint: 0.0.0.0:9464 + logging: + loglevel: info + +processors: + batch: + timeout: 5s + +service: + pipelines: + metrics: + receivers: [otlp] + processors: [batch] + exporters: [prometheus] + traces: + receivers: [otlp] + processors: [batch] + exporters: [logging] diff --git a/metrics/prometheus.yml b/metrics/prometheus.yml new file mode 100644 index 0000000..37c20a3 --- /dev/null +++ b/metrics/prometheus.yml @@ -0,0 +1,11 @@ +global: + scrape_interval: 15s + +scrape_configs: + - job_name: cyberdevstudio + static_configs: + - targets: + - api:6813 + - llmserver:6988 + - auth:6971 + metrics_path: /metrics diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..06b6051 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "apps/*" diff --git a/sandbox/Cargo.toml b/sandbox/Cargo.toml new file mode 100644 index 0000000..c454f17 --- /dev/null +++ b/sandbox/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "cyberdev_sandbox" +version = "0.0.1" +edition = "2021" + +[dependencies] +anyhow = "1" +thiserror = "1" + +[dev-dependencies] +tempfile = "3" diff --git a/sandbox/src/fs.rs b/sandbox/src/fs.rs new file mode 100644 index 0000000..d1a6691 --- /dev/null +++ b/sandbox/src/fs.rs @@ -0,0 +1,194 @@ +//! Filesystem sandbox module. +//! +//! Provides safe wrappers for working with project files inside a bounded +//! workspace directory. The helpers ensure that callers cannot escape the +//! configured root via `..` traversal or absolute paths and enforce a +//! conservative file size limit so runaway writes do not starve the host. + +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; + +use thiserror::Error; + +/// Environment variable that controls the sandbox root. +pub const WORKSPACE_ROOT_ENV: &str = "CYBERDEV_WORKSPACE_ROOT"; + +/// Default directory (relative to the current working directory) that will be +/// used when `CYBERDEV_WORKSPACE_ROOT` is not provided. +const DEFAULT_WORKSPACE_DIR: &str = "workspace"; + +/// Maximum number of bytes a single file operation may write. +const MAX_FILE_SIZE_BYTES: usize = 2 * 1024 * 1024; // 2 MiB + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum FsError { + /// The caller attempted to provide an absolute path which is not allowed. + #[error("absolute paths are not permitted inside the sandbox: {0}")] + AbsolutePath(PathBuf), + + /// The caller attempted to escape the workspace root via `..` segments. + #[error("path traversal outside the workspace root is not allowed")] + TraversalAttempt, + + /// The workspace root could not be prepared as a directory. + #[error("workspace root is not a directory: {0}")] + WorkspaceRootInvalid(PathBuf), + + /// The requested file is larger than the configured limit. + #[error("file exceeds maximum size of {limit} bytes (attempted {size} bytes)")] + FileTooLarge { size: usize, limit: usize }, + + /// Wrapper around [`std::io::Error`]. + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +/// Writes content to a sandbox-managed file. +pub fn write_file(path: P, contents: C) -> Result<(), FsError> +where + P: AsRef, + C: AsRef<[u8]>, +{ + let data = contents.as_ref(); + if data.len() > MAX_FILE_SIZE_BYTES { + return Err(FsError::FileTooLarge { + size: data.len(), + limit: MAX_FILE_SIZE_BYTES, + }); + } + + let target = resolve_workspace_path(path.as_ref())?; + + if let Some(parent) = target.parent() { + fs::create_dir_all(parent)?; + } + + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&target)?; + file.write_all(data)?; + file.sync_all()?; + + Ok(()) +} + +/// Reads a file from the sandboxed workspace. +pub fn read_file>(path: P) -> Result { + let target = resolve_workspace_path(path.as_ref())?; + + let metadata = fs::metadata(&target)?; + if metadata.len() as usize > MAX_FILE_SIZE_BYTES { + return Err(FsError::FileTooLarge { + size: metadata.len() as usize, + limit: MAX_FILE_SIZE_BYTES, + }); + } + + Ok(std::fs::read_to_string(target)?) +} + +pub(crate) fn resolve_workspace_path(path: &Path) -> Result { + let sanitized = sanitize_relative_path(path)?; + let root = workspace_root()?; + Ok(root.join(sanitized)) +} + +pub(crate) fn workspace_root() -> Result { + let env_root = std::env::var(WORKSPACE_ROOT_ENV).ok().map(PathBuf::from); + + let mut root = env_root.unwrap_or_else(|| PathBuf::from(DEFAULT_WORKSPACE_DIR)); + + if root.is_relative() { + root = std::env::current_dir()?.join(root); + } + + if root.exists() { + if !root.is_dir() { + return Err(FsError::WorkspaceRootInvalid(root)); + } + } else { + fs::create_dir_all(&root)?; + } + + Ok(root) +} + +fn sanitize_relative_path(path: &Path) -> Result { + let mut sanitized = PathBuf::new(); + + for component in path.components() { + match component { + Component::Prefix(_) | Component::RootDir => { + return Err(FsError::AbsolutePath(path.to_path_buf())); + } + Component::CurDir => {} + Component::ParentDir => { + if !sanitized.pop() { + return Err(FsError::TraversalAttempt); + } + } + Component::Normal(part) => sanitized.push(part), + } + } + + Ok(sanitized) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn with_temp_workspace(test: F) { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + std::env::set_var(WORKSPACE_ROOT_ENV, temp_dir.path()); + test(); + std::env::remove_var(WORKSPACE_ROOT_ENV); + } + + #[test] + fn write_and_read_roundtrip() { + with_temp_workspace(|| { + write_file("nested/hello.txt", "cyberdev").expect("write succeeded"); + let contents = read_file("nested/hello.txt").expect("read succeeded"); + assert_eq!(contents, "cyberdev"); + }); + } + + #[test] + fn rejects_absolute_paths() { + with_temp_workspace(|| { + let path = PathBuf::from("/etc/passwd"); + let err = write_file(&path, "nope").expect_err("should reject absolute path"); + assert!(matches!(err, FsError::AbsolutePath(p) if p == path)); + }); + } + + #[test] + fn rejects_traversal() { + with_temp_workspace(|| { + let err = write_file("../escape.txt", "nope").expect_err("should reject traversal"); + assert!(matches!(err, FsError::TraversalAttempt)); + }); + } + + #[test] + fn respects_size_limit() { + with_temp_workspace(|| { + let big = vec![0_u8; MAX_FILE_SIZE_BYTES + 1]; + let err = write_file("big.bin", &big).expect_err("should reject big file"); + assert!(matches!(err, FsError::FileTooLarge { .. })); + }); + } + + #[test] + fn workspace_root_must_be_directory() { + let file = tempfile::NamedTempFile::new().expect("temp file"); + std::env::set_var(WORKSPACE_ROOT_ENV, file.path()); + let result = write_file("foo.txt", "bar"); + std::env::remove_var(WORKSPACE_ROOT_ENV); + assert!(matches!(result, Err(FsError::WorkspaceRootInvalid(_)))); + } +} diff --git a/sandbox/src/lib.rs b/sandbox/src/lib.rs new file mode 100644 index 0000000..fbf73a9 --- /dev/null +++ b/sandbox/src/lib.rs @@ -0,0 +1,15 @@ +//! Sandbox workspace crate placeholder. +//! +//! This crate will expose safe APIs for interacting with the CyberDevStudio +//! execution environments. + +pub mod fs; +pub mod micro; +pub mod run; +pub mod wasm; + +pub use fs::{read_file, write_file, FsError, WORKSPACE_ROOT_ENV}; +pub use run::{ + execute, execute_with_config, ExecuteConfig, ExecutionResult, RunError, + DEFAULT_EXECUTION_TIMEOUT, +}; diff --git a/sandbox/src/micro.rs b/sandbox/src/micro.rs new file mode 100644 index 0000000..705e74e --- /dev/null +++ b/sandbox/src/micro.rs @@ -0,0 +1,7 @@ +//! MicroVM sandbox placeholder. +//! +//! Intended to encapsulate Firecracker-based executions for high isolation. + +pub fn boot_microvm(_image: &str) -> anyhow::Result<()> { + unimplemented!("MicroVM sandbox is not implemented yet"); +} diff --git a/sandbox/src/run.rs b/sandbox/src/run.rs new file mode 100644 index 0000000..13dfae8 --- /dev/null +++ b/sandbox/src/run.rs @@ -0,0 +1,326 @@ +//! Process execution sandbox utilities. +//! +//! Provides a restrictive wrapper around spawning commands from the workspace +//! with sane defaults: an allow-listed set of binaries, trimmed environment, +//! bounded stdout/stderr collection, and a hard execution timeout. + +use std::fmt; +use std::fs; +use std::io::{self, Read}; +use std::path::PathBuf; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use thiserror::Error; + +use crate::fs::{resolve_workspace_path, workspace_root, FsError}; + +/// Default amount of time a command is permitted to run. +pub const DEFAULT_EXECUTION_TIMEOUT: Duration = Duration::from_secs(5); + +/// Maximum number of bytes captured from stdout/stderr. +const MAX_OUTPUT_BYTES: usize = 512 * 1024; // 512 KiB + +/// Minimal search path for spawned commands. +const DEFAULT_PATH: &str = "/usr/local/bin:/usr/bin:/bin"; + +/// Commands that may be executed by the sandbox. +const ALLOWED_COMMANDS: &[&str] = &[ + "sh", + "/bin/sh", + "bash", + "/bin/bash", + "python3", + "/usr/bin/python3", + "node", + "/usr/bin/node", + "deno", + "/usr/bin/deno", + "cargo", + "/usr/bin/cargo", + "npm", + "/usr/bin/npm", + "pnpm", + "/usr/bin/pnpm", + "yarn", + "/usr/bin/yarn", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum OutputStream { + Stdout, + Stderr, +} + +impl fmt::Display for OutputStream { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OutputStream::Stdout => write!(f, "stdout"), + OutputStream::Stderr => write!(f, "stderr"), + } + } +} + +/// Errors that may arise while executing sandboxed commands. +#[derive(Debug, Error)] +pub enum RunError { + #[error("command `{0}` is not permitted inside the sandbox")] + CommandNotAllowed(String), + + #[error("execution timed out after {0:?}")] + Timeout(Duration), + + #[error("output on {stream} exceeded limit of {limit} bytes")] + OutputLimit { stream: OutputStream, limit: usize }, + + #[error("child process did not expose {stream} stream")] + MissingStream { stream: OutputStream }, + + #[error("output reader thread panicked")] + ReaderThread, + + #[error(transparent)] + Workspace(#[from] FsError), + + #[error(transparent)] + Io(#[from] io::Error), +} + +/// Configuration options for sandboxed command execution. +#[derive(Debug, Default, Clone)] +pub struct ExecuteConfig { + /// Optional override for the default execution timeout. + pub timeout: Option, + /// Optional working directory relative to the workspace root. + pub working_directory: Option, +} + +/// Result of a sandboxed process execution. +#[derive(Debug)] +pub struct ExecutionResult { + /// Exit status returned by the child process. + pub status: ExitStatus, + /// Captured stdout (truncated to [`MAX_OUTPUT_BYTES`]). + pub stdout: String, + /// Captured stderr (truncated to [`MAX_OUTPUT_BYTES`]). + pub stderr: String, + /// Total duration the command was allowed to run. + pub duration: Duration, +} + +/// Executes a command inside the sandbox and captures its output with default settings. +pub fn execute(command: &str, args: &[String]) -> Result { + execute_with_config(command, args, ExecuteConfig::default()) +} + +/// Executes a command inside the sandbox with the provided configuration. +pub fn execute_with_config( + command: &str, + args: &[String], + mut config: ExecuteConfig, +) -> Result { + if !is_command_allowed(command) { + return Err(RunError::CommandNotAllowed(command.to_string())); + } + + let workspace = workspace_root()?; + let home_dir = workspace.join(".sandbox_home"); + fs::create_dir_all(&home_dir)?; + + let timeout = config.timeout.unwrap_or(DEFAULT_EXECUTION_TIMEOUT); + + let working_directory = if let Some(dir) = config.working_directory.take() { + resolve_workspace_path(&dir)? + } else { + workspace.clone() + }; + + let mut cmd = Command::new(command); + cmd.args(args) + .current_dir(&working_directory) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + cmd.env_clear(); + cmd.env("PATH", DEFAULT_PATH); + cmd.env("HOME", &home_dir); + cmd.env(crate::fs::WORKSPACE_ROOT_ENV, &workspace); + + let mut child = cmd.spawn()?; + + let stdout = child.stdout.take().ok_or(RunError::MissingStream { + stream: OutputStream::Stdout, + })?; + let stderr = child.stderr.take().ok_or(RunError::MissingStream { + stream: OutputStream::Stderr, + })?; + + let stdout_handle = spawn_reader(stdout, OutputStream::Stdout); + let stderr_handle = spawn_reader(stderr, OutputStream::Stderr); + + let start = Instant::now(); + let status = match wait_with_timeout(&mut child, timeout) { + Ok(status) => status, + Err(err) => { + let _ = join_reader(stdout_handle); + let _ = join_reader(stderr_handle); + return Err(err); + } + }; + let duration = start.elapsed(); + + let stdout_bytes = join_reader(stdout_handle)?; + let stderr_bytes = join_reader(stderr_handle)?; + + Ok(ExecutionResult { + status, + stdout: String::from_utf8_lossy(&stdout_bytes).to_string(), + stderr: String::from_utf8_lossy(&stderr_bytes).to_string(), + duration, + }) +} + +fn spawn_reader(reader: R, stream: OutputStream) -> thread::JoinHandle, RunError>> +where + R: Read + Send + 'static, +{ + thread::spawn(move || read_stream(reader, stream)) +} + +fn read_stream(mut reader: R, stream: OutputStream) -> Result, RunError> { + let mut buf = Vec::new(); + let mut chunk = [0_u8; 8192]; + + loop { + let read = reader.read(&mut chunk)?; + if read == 0 { + break; + } + + if buf.len() + read > MAX_OUTPUT_BYTES { + return Err(RunError::OutputLimit { + stream, + limit: MAX_OUTPUT_BYTES, + }); + } + + buf.extend_from_slice(&chunk[..read]); + } + + Ok(buf) +} + +fn join_reader(handle: thread::JoinHandle, RunError>>) -> Result, RunError> { + match handle.join() { + Ok(result) => result, + Err(_) => Err(RunError::ReaderThread), + } +} + +fn wait_with_timeout(child: &mut Child, timeout: Duration) -> Result { + let start = Instant::now(); + + loop { + if let Some(status) = child.try_wait()? { + return Ok(status); + } + + if start.elapsed() >= timeout { + child.kill().ok(); + let _ = child.wait(); + return Err(RunError::Timeout(timeout)); + } + + thread::sleep(Duration::from_millis(25)); + } +} + +fn is_command_allowed(command: &str) -> bool { + ALLOWED_COMMANDS.iter().any(|allowed| *allowed == command) +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::path::PathBuf; + use std::time::Duration; + + use crate::fs::{workspace_root, WORKSPACE_ROOT_ENV}; + + fn with_temp_workspace(test: F) { + let temp_dir = tempfile::tempdir().expect("create temp dir"); + std::env::set_var(WORKSPACE_ROOT_ENV, temp_dir.path()); + test(); + std::env::remove_var(WORKSPACE_ROOT_ENV); + } + + #[test] + fn executes_allowed_command() { + with_temp_workspace(|| { + let result = execute("sh", &["-c".to_string(), "printf cyberdev".to_string()]) + .expect("execution succeeds"); + + assert!(result.status.success()); + assert_eq!(result.stdout, "cyberdev"); + assert_eq!(result.stderr, ""); + assert!(result.duration <= DEFAULT_EXECUTION_TIMEOUT); + }); + } + + #[test] + fn rejects_disallowed_command() { + with_temp_workspace(|| { + let err = execute("rm", &[]).expect_err("rm should be disallowed"); + assert!(matches!(err, RunError::CommandNotAllowed(cmd) if cmd == "rm")); + }); + } + + #[test] + fn terminates_on_timeout() { + with_temp_workspace(|| { + let mut config = ExecuteConfig::default(); + config.timeout = Some(Duration::from_millis(200)); + + let err = + execute_with_config("sh", &["-c".to_string(), "sleep 10".to_string()], config) + .expect_err("execution should time out"); + assert!(matches!(err, RunError::Timeout(t) if t == Duration::from_millis(200))); + }); + } + + #[test] + fn honors_custom_working_directory() { + with_temp_workspace(|| { + let root = workspace_root().expect("workspace root"); + let nested = root.join("nested"); + fs::create_dir_all(&nested).expect("create nested dir"); + + let mut config = ExecuteConfig::default(); + config.working_directory = Some(PathBuf::from("nested")); + + let result = execute_with_config("sh", &["-c".to_string(), "pwd".to_string()], config) + .expect("execution succeeds"); + + assert!(result.status.success()); + assert!(result.stdout.trim_end().ends_with("/nested")); + }); + } + + #[test] + fn rejects_working_directory_escape() { + with_temp_workspace(|| { + let mut config = ExecuteConfig::default(); + config.working_directory = Some(PathBuf::from("../escape")); + + let err = execute_with_config("sh", &[], config) + .expect_err("should fail resolving working directory"); + assert!(matches!( + err, + RunError::Workspace(FsError::TraversalAttempt) + )); + }); + } +} diff --git a/sandbox/src/wasm.rs b/sandbox/src/wasm.rs new file mode 100644 index 0000000..a80aa3b --- /dev/null +++ b/sandbox/src/wasm.rs @@ -0,0 +1,11 @@ +//! WebAssembly sandbox placeholder. +//! +//! Will manage Wasmtime instances, capability injection, and timeout handling. + +pub struct WasmSandbox; + +impl WasmSandbox { + pub fn execute_module(_bytes: &[u8], _entry: &str) -> anyhow::Result<()> { + unimplemented!("WASM sandbox is not implemented yet"); + } +} diff --git a/schemas/rpc/fs.write.json b/schemas/rpc/fs.write.json new file mode 100644 index 0000000..6f3e01d --- /dev/null +++ b/schemas/rpc/fs.write.json @@ -0,0 +1,18 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "fs.write", + "type": "object", + "properties": { + "method": { "const": "fs.write" }, + "params": { + "type": "object", + "required": ["path", "contents"], + "properties": { + "path": { "type": "string" }, + "contents": { "type": "string" }, + "encoding": { "type": "string", "enum": ["utf8", "base64"] } + } + } + }, + "required": ["method", "params"] +} diff --git a/schemas/rpc/llm.chat.json b/schemas/rpc/llm.chat.json new file mode 100644 index 0000000..e5585e0 --- /dev/null +++ b/schemas/rpc/llm.chat.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "llm.chat", + "type": "object", + "properties": { + "method": { "const": "llm.chat" }, + "params": { + "type": "object", + "required": ["model", "messages"], + "properties": { + "model": { "type": "string" }, + "messages": { + "type": "array", + "items": { + "type": "object", + "required": ["role", "content"], + "properties": { + "role": { "type": "string", "enum": ["system", "user", "assistant", "tool"] }, + "content": { "type": "string" } + } + } + }, + "temperature": { "type": "number", "minimum": 0, "maximum": 1.5 }, + "max_tokens": { "type": "integer", "minimum": 1, "maximum": 8192 }, + "stream": { "type": "boolean" } + } + } + }, + "required": ["method", "params"] +} diff --git a/schemas/rpc/run.exec.json b/schemas/rpc/run.exec.json new file mode 100644 index 0000000..bc47caf --- /dev/null +++ b/schemas/rpc/run.exec.json @@ -0,0 +1,22 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "run.exec", + "type": "object", + "properties": { + "method": { "const": "run.exec" }, + "params": { + "type": "object", + "required": ["command"], + "properties": { + "command": { "type": "string" }, + "args": { + "type": "array", + "items": { "type": "string" } + }, + "timeout_ms": { "type": "integer", "minimum": 1, "maximum": 120000 }, + "working_directory": { "type": "string" } + } + } + }, + "required": ["method", "params"] +} diff --git a/tests/e2e.rs b/tests/e2e.rs new file mode 100644 index 0000000..1bb3069 --- /dev/null +++ b/tests/e2e.rs @@ -0,0 +1,7 @@ +//! Placeholder E2E test harness. + +#[test] +fn e2e_placeholder() { + // TODO: spin up docker-compose stack and perform RPC walkthroughs. + assert!(true); +} diff --git a/tests/fs_write.rs b/tests/fs_write.rs new file mode 100644 index 0000000..e793125 --- /dev/null +++ b/tests/fs_write.rs @@ -0,0 +1,7 @@ +//! Placeholder test for fs.write RPC once implemented. + +#[test] +fn fs_write_placeholder() { + // TODO: replace with integration against sandbox::fs once implemented. + assert!(true); +} diff --git a/tests/run_exec.rs b/tests/run_exec.rs new file mode 100644 index 0000000..6fe4c7b --- /dev/null +++ b/tests/run_exec.rs @@ -0,0 +1,33 @@ +//! Integration smoke tests for the sandbox process execution helper. + +use cyberdev_sandbox::{execute, RunError, WORKSPACE_ROOT_ENV}; + +#[test] +fn run_exec_smoke() { + let temp_dir = tempfile::tempdir().expect("create temp workspace"); + std::env::set_var(WORKSPACE_ROOT_ENV, temp_dir.path()); + + let result = execute( + "sh", + &["-c".to_string(), "printf sandbox-ok".to_string()], + ) + .expect("sandbox execute succeeds"); + + std::env::remove_var(WORKSPACE_ROOT_ENV); + + assert!(result.status.success()); + assert_eq!(result.stdout, "sandbox-ok"); + assert_eq!(result.stderr, ""); +} + +#[test] +fn run_exec_disallows_unknown_binary() { + let temp_dir = tempfile::tempdir().expect("create temp workspace"); + std::env::set_var(WORKSPACE_ROOT_ENV, temp_dir.path()); + + let err = execute("rm", &[]).expect_err("rm should be blocked"); + + std::env::remove_var(WORKSPACE_ROOT_ENV); + + assert!(matches!(err, RunError::CommandNotAllowed(cmd) if cmd == "rm")); +} diff --git a/themes/NeonCyberNight.css b/themes/NeonCyberNight.css new file mode 100644 index 0000000..6fe15c6 --- /dev/null +++ b/themes/NeonCyberNight.css @@ -0,0 +1,27 @@ +:root { + --background: #080818; + --foreground: #e0f7ff; + --accent: #00f6ff; + --accent-secondary: #ff00d4; + --panel: rgba(8, 8, 24, 0.92); + --border: rgba(0, 246, 255, 0.35); +} + +body { + background: radial-gradient(circle at 20% 20%, #101040, #040410 60%); + color: var(--foreground); + font-family: 'JetBrains Mono', monospace; +} + +.cyber-panel { + background: var(--panel); + border: 1px solid var(--border); + box-shadow: 0 0 20px rgba(0, 246, 255, 0.2); +} + +.button-primary { + background: linear-gradient(120deg, var(--accent), var(--accent-secondary)); + color: #040410; + text-transform: uppercase; + letter-spacing: 0.1em; +} diff --git a/themes/SerialSteel.css b/themes/SerialSteel.css new file mode 100644 index 0000000..215d777 --- /dev/null +++ b/themes/SerialSteel.css @@ -0,0 +1,25 @@ +:root { + --background: #121212; + --foreground: #f5f5f5; + --accent: #5cdb95; + --accent-secondary: #379683; + --panel: rgba(18, 18, 18, 0.92); + --border: rgba(92, 219, 149, 0.35); +} + +body { + background: linear-gradient(160deg, #1f1f1f, #0d0d0d); + color: var(--foreground); + font-family: 'Fira Code', monospace; +} + +.panel { + background: var(--panel); + border: 1px solid var(--border); + box-shadow: 0 0 10px rgba(92, 219, 149, 0.15); +} + +.button-secondary { + background: linear-gradient(140deg, var(--accent-secondary), var(--accent)); + color: #0d0d0d; +}