Skip to content

Commit c1f0db3

Browse files
author
Platform
committed
feat: agent ZIP upload frontend with env vars + SUDO_PASSWORD auth
- GET / serves HTML upload form (password-protected via SUDO_PASSWORD env) - POST /upload-agent accepts multipart: ZIP archive + env vars (KEY=VALUE) - GET /agent-code returns stored ZIP (password in X-Password header) - ZIP validated, env vars parsed and stored in-memory - Constant-time password comparison for security
1 parent 492d068 commit c1f0db3

File tree

3 files changed

+235
-2
lines changed

3 files changed

+235
-2
lines changed

src/config.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub struct Config {
3131
pub consensus_threshold: f64,
3232
pub consensus_ttl_secs: u64,
3333
pub max_pending_consensus: usize,
34+
pub sudo_password: Option<String>,
3435
}
3536

3637
impl Config {
@@ -71,6 +72,7 @@ impl Config {
7172
"MAX_PENDING_CONSENSUS",
7273
DEFAULT_MAX_PENDING_CONSENSUS,
7374
),
75+
sudo_password: std::env::var("SUDO_PASSWORD").ok().filter(|s| !s.is_empty()),
7476
})
7577
}
7678

src/handlers.rs

Lines changed: 231 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
use axum::{
22
extract::{Multipart, State},
3-
http::StatusCode,
4-
response::{IntoResponse, Json, Response},
3+
http::{header, StatusCode},
4+
response::{Html, IntoResponse, Json, Response},
55
routing::{get, post},
66
Router,
77
};
88
use chrono::Utc;
99
use serde::Serialize;
10+
use std::collections::HashMap;
1011
use std::sync::atomic::Ordering;
1112
use std::sync::Arc;
13+
use tokio::sync::RwLock;
1214
use tracing::warn;
1315

1416
use crate::auth::{self, NonceStore};
@@ -31,13 +33,18 @@ pub struct AppState {
3133
pub started_at: chrono::DateTime<Utc>,
3234
pub validator_whitelist: Arc<ValidatorWhitelist>,
3335
pub consensus_manager: Arc<ConsensusManager>,
36+
pub agent_archive: Arc<RwLock<Option<Vec<u8>>>>,
37+
pub agent_env: Arc<RwLock<HashMap<String, String>>>,
3438
}
3539

3640
pub fn router(state: Arc<AppState>) -> Router {
3741
Router::new()
42+
.route("/", get(upload_frontend))
3843
.route("/health", get(health))
3944
.route("/status", get(status))
4045
.route("/metrics", get(metrics))
46+
.route("/upload-agent", post(upload_agent))
47+
.route("/agent-code", get(get_agent_code))
4148
.route("/submit", post(submit_batch))
4249
.route("/batch/{id}", get(get_batch))
4350
.route("/batch/{id}/tasks", get(get_batch_tasks))
@@ -55,6 +62,228 @@ async fn health() -> impl IntoResponse {
5562
Json(serde_json::json!({ "status": "ok" }))
5663
}
5764

65+
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
66+
if a.len() != b.len() {
67+
return false;
68+
}
69+
let mut diff = 0u8;
70+
for (x, y) in a.iter().zip(b.iter()) {
71+
diff |= x ^ y;
72+
}
73+
diff == 0
74+
}
75+
76+
async fn upload_frontend(State(state): State<Arc<AppState>>) -> impl IntoResponse {
77+
let enabled = state.config.sudo_password.is_some();
78+
let version = env!("CARGO_PKG_VERSION");
79+
80+
if !enabled {
81+
return Html(format!(
82+
r##"<!DOCTYPE html><html><head><meta charset="utf-8"><title>term-executor</title>
83+
<style>body{{background:#0a0a0a;color:#ff4444;font-family:monospace;display:flex;justify-content:center;padding:60px}}</style>
84+
</head><body><div>term-executor v{version} — Upload disabled (SUDO_PASSWORD not set)</div></body></html>"##
85+
));
86+
}
87+
88+
Html(format!(
89+
r##"<!DOCTYPE html>
90+
<html lang="en">
91+
<head>
92+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
93+
<title>term-executor</title>
94+
<style>
95+
*{{margin:0;padding:0;box-sizing:border-box}}
96+
body{{background:#0a0a0a;color:#e0e0e0;font-family:monospace;display:flex;justify-content:center;padding:40px 20px}}
97+
.c{{max-width:720px;width:100%}}
98+
h1{{color:#b2ff22;margin-bottom:8px;font-size:1.4em}}
99+
.sub{{color:#666;margin-bottom:24px;font-size:0.85em}}
100+
label{{display:block;color:#888;margin:12px 0 4px;font-size:0.85em}}
101+
input[type=password],input[type=file]{{width:100%;padding:10px;background:#111;border:1px solid #333;color:#e0e0e0;border-radius:4px;font-family:monospace}}
102+
textarea{{width:100%;height:120px;padding:10px;background:#111;border:1px solid #333;color:#ffbd2e;border-radius:4px;font-family:monospace;font-size:12px;resize:vertical}}
103+
button{{margin-top:20px;padding:12px 32px;background:#b2ff22;color:#0a0a0a;border:none;border-radius:4px;font-weight:bold;cursor:pointer;font-family:monospace;font-size:1em;width:100%}}
104+
button:hover{{background:#9de01a}}
105+
button:disabled{{background:#333;color:#666;cursor:not-allowed}}
106+
.r{{margin-top:20px;padding:16px;border-radius:4px;display:none;font-size:0.9em;word-break:break-all}}
107+
.ok{{background:#0d1f00;border:1px solid #2a5a00;color:#b2ff22}}
108+
.err{{background:#1f0000;border:1px solid #5a0000;color:#ff4444}}
109+
.info{{color:#555;font-size:0.75em;margin-top:16px;line-height:1.6}}
110+
</style>
111+
</head>
112+
<body>
113+
<div class="c">
114+
<h1>term-executor</h1>
115+
<p class="sub">v{version} — Agent Upload</p>
116+
<form id="f" onsubmit="return up(event)">
117+
<label>Password</label>
118+
<input type="password" id="pw" required autocomplete="off">
119+
<label>Agent ZIP (project with requirements.txt + agent.py)</label>
120+
<input type="file" id="zip" accept=".zip" required>
121+
<label>Environment Variables (one per line: KEY=VALUE)</label>
122+
<textarea id="env" placeholder="CHUTES_API_KEY=cpk_...&#10;MODEL_NAME=deepseek-ai/DeepSeek-V3-0324-TEE"></textarea>
123+
<button type="submit" id="btn">Upload Agent</button>
124+
</form>
125+
<div id="res" class="r"></div>
126+
<div class="info">
127+
Upload a ZIP containing your agent project (agent.py, requirements.txt, etc).<br>
128+
Env vars are injected when running agent.py. Stored in-memory only. TLS by Basilica.
129+
</div>
130+
</div>
131+
<script>
132+
async function up(e){{
133+
e.preventDefault();
134+
const btn=document.getElementById('btn'),res=document.getElementById('res');
135+
const file=document.getElementById('zip').files[0];
136+
if(!file){{res.style.display='block';res.className='r err';res.textContent='No ZIP selected';return false}}
137+
btn.disabled=true;btn.textContent='Uploading...';
138+
const fd=new FormData();
139+
fd.append('password',document.getElementById('pw').value);
140+
fd.append('archive',file);
141+
fd.append('env_vars',document.getElementById('env').value);
142+
try{{
143+
const r=await fetch('/upload-agent',{{method:'POST',body:fd}});
144+
const d=await r.json();
145+
res.style.display='block';
146+
if(r.ok){{
147+
res.className='r ok';
148+
res.textContent='Uploaded — hash: '+d.archive_hash+' ('+d.size_bytes+' bytes, '+d.files_count+' files, '+d.env_count+' env vars)';
149+
}}else{{
150+
res.className='r err';
151+
res.textContent='Error: '+(d.message||d.error||'unknown');
152+
}}
153+
}}catch(err){{
154+
res.style.display='block';res.className='r err';res.textContent='Network error: '+err.message;
155+
}}finally{{btn.disabled=false;btn.textContent='Upload Agent'}}
156+
return false;
157+
}}
158+
</script>
159+
</body>
160+
</html>"##
161+
))
162+
}
163+
164+
async fn upload_agent(
165+
State(state): State<Arc<AppState>>,
166+
mut multipart: Multipart,
167+
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
168+
let expected = state.config.sudo_password.as_deref().ok_or_else(|| {
169+
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "upload_disabled", "message": "SUDO_PASSWORD not configured"})))
170+
})?;
171+
172+
let mut password: Option<String> = None;
173+
let mut archive_data: Option<Vec<u8>> = None;
174+
let mut env_vars_raw: Option<String> = None;
175+
176+
while let Ok(Some(field)) = multipart.next_field().await {
177+
let name = field.name().unwrap_or("").to_string();
178+
match name.as_str() {
179+
"password" => {
180+
password = field.text().await.ok();
181+
}
182+
"archive" | "file" => {
183+
let bytes = field.bytes().await.map_err(|e| {
184+
(StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": format!("Upload read failed: {}", e)})))
185+
})?;
186+
archive_data = Some(bytes.to_vec());
187+
}
188+
"env_vars" => {
189+
env_vars_raw = field.text().await.ok();
190+
}
191+
_ => {}
192+
}
193+
}
194+
195+
let pw = password.unwrap_or_default();
196+
if !constant_time_eq(pw.as_bytes(), expected.as_bytes()) {
197+
return Err((StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid_password", "message": "Invalid password"}))));
198+
}
199+
200+
let archive_bytes = archive_data.ok_or_else(|| {
201+
(StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "missing_archive", "message": "No ZIP file uploaded"})))
202+
})?;
203+
204+
if archive_bytes.is_empty() {
205+
return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "empty_archive", "message": "ZIP file is empty"}))));
206+
}
207+
208+
const MAX_AGENT_SIZE: usize = 50 * 1024 * 1024; // 50MB
209+
if archive_bytes.len() > MAX_AGENT_SIZE {
210+
return Err((StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "archive_too_large", "message": "ZIP exceeds 50MB limit"}))));
211+
}
212+
213+
// Validate it's a real ZIP
214+
let files_count = {
215+
let cursor = std::io::Cursor::new(&archive_bytes);
216+
let archive = zip::ZipArchive::new(cursor).map_err(|e| {
217+
(StatusCode::BAD_REQUEST, Json(serde_json::json!({"error": "invalid_zip", "message": format!("Not a valid ZIP: {}", e)})))
218+
})?;
219+
archive.len()
220+
};
221+
222+
let hash = {
223+
let mut hasher = Sha256::new();
224+
hasher.update(&archive_bytes);
225+
hex::encode(hasher.finalize())
226+
};
227+
let size = archive_bytes.len();
228+
229+
// Parse env vars: KEY=VALUE per line, skip empty/comments
230+
let mut env_map = HashMap::new();
231+
if let Some(raw) = &env_vars_raw {
232+
for line in raw.lines() {
233+
let trimmed = line.trim();
234+
if trimmed.is_empty() || trimmed.starts_with('#') {
235+
continue;
236+
}
237+
if let Some(eq_pos) = trimmed.find('=') {
238+
let key = trimmed[..eq_pos].trim().to_string();
239+
let val = trimmed[eq_pos + 1..].trim().to_string();
240+
if !key.is_empty() {
241+
env_map.insert(key, val);
242+
}
243+
}
244+
}
245+
}
246+
let env_count = env_map.len();
247+
248+
*state.agent_archive.write().await = Some(archive_bytes);
249+
*state.agent_env.write().await = env_map;
250+
251+
Ok(Json(serde_json::json!({
252+
"success": true,
253+
"archive_hash": hash,
254+
"size_bytes": size,
255+
"files_count": files_count,
256+
"env_count": env_count,
257+
})))
258+
}
259+
260+
async fn get_agent_code(
261+
State(state): State<Arc<AppState>>,
262+
headers: axum::http::HeaderMap,
263+
) -> Result<Response, (StatusCode, Json<serde_json::Value>)> {
264+
let expected = state.config.sudo_password.as_deref().ok_or_else(|| {
265+
(StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "disabled"})))
266+
})?;
267+
268+
let password = headers.get("x-password").and_then(|v| v.to_str().ok()).unwrap_or("");
269+
if !constant_time_eq(password.as_bytes(), expected.as_bytes()) {
270+
return Err((StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "invalid_password"}))));
271+
}
272+
273+
let archive = state.agent_archive.read().await;
274+
match archive.as_deref() {
275+
Some(bytes) => Ok((
276+
StatusCode::OK,
277+
[
278+
(header::CONTENT_TYPE, "application/zip"),
279+
(header::CONTENT_DISPOSITION, "attachment; filename=\"agent.zip\""),
280+
],
281+
bytes.to_vec(),
282+
).into_response()),
283+
None => Err((StatusCode::NOT_FOUND, Json(serde_json::json!({"error": "no_agent", "message": "No agent archive uploaded yet"})))),
284+
}
285+
}
286+
58287
#[derive(Serialize)]
59288
struct StatusResponse {
60289
version: String,

src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ async fn main() {
6262
started_at: chrono::Utc::now(),
6363
validator_whitelist: validator_whitelist.clone(),
6464
consensus_manager: consensus_manager.clone(),
65+
agent_archive: Arc::new(tokio::sync::RwLock::new(None)),
66+
agent_env: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
6567
});
6668

6769
let app = handlers::router(state);

0 commit comments

Comments
 (0)