11use 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} ;
88use chrono:: Utc ;
99use serde:: Serialize ;
10+ use std:: collections:: HashMap ;
1011use std:: sync:: atomic:: Ordering ;
1112use std:: sync:: Arc ;
13+ use tokio:: sync:: RwLock ;
1214use tracing:: warn;
1315
1416use 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
3640pub 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_... 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 ) ]
59288struct StatusResponse {
60289 version : String ,
0 commit comments