1use axum::{
21 Json,
22 extract::{Query, State},
23 http::{HeaderMap, StatusCode},
24 response::IntoResponse,
25};
26use serde::{Deserialize, Serialize};
27use std::path::{Path, PathBuf};
28use std::time::SystemTime;
29use zeroclaw_runtime::agent::personality::{EDITABLE_PERSONALITY_FILES, MAX_FILE_CHARS};
30use zeroclaw_runtime::agent::personality_templates::{TemplateContext, render_preset_default};
31
32use super::AppState;
33use super::api::require_auth;
34
35#[derive(Debug, Deserialize, Default)]
38pub struct AgentQuery {
39 #[serde(default)]
42 pub agent: Option<String>,
43}
44
45#[derive(Debug, Deserialize, Default)]
46pub struct TemplateQuery {
47 #[serde(default)]
50 pub preset: Option<String>,
51 #[serde(default)]
52 pub agent_name: Option<String>,
53 #[serde(default)]
54 pub user_name: Option<String>,
55 #[serde(default)]
56 pub timezone: Option<String>,
57 #[serde(default)]
58 pub communication_style: Option<String>,
59 #[serde(default)]
62 pub include_memory: Option<bool>,
63 #[serde(default)]
67 pub agent: Option<String>,
68}
69
70#[derive(Debug, Serialize)]
71pub struct TemplateFile {
72 pub filename: &'static str,
73 pub content: String,
74}
75
76#[derive(Debug, Serialize)]
77pub struct TemplateResponse {
78 pub preset: &'static str,
79 pub files: Vec<TemplateFile>,
80}
81
82#[derive(Debug, Serialize)]
83pub struct PersonalityIndexEntry {
84 pub filename: &'static str,
85 pub exists: bool,
86 pub size: u64,
87 pub mtime_ms: Option<i64>,
88}
89
90#[derive(Debug, Serialize)]
91pub struct PersonalityIndex {
92 pub files: Vec<PersonalityIndexEntry>,
93 pub max_chars: usize,
94}
95
96#[derive(Debug, Serialize)]
97pub struct PersonalityFileResponse {
98 pub filename: String,
99 pub content: String,
100 pub exists: bool,
101 pub truncated: bool,
102 pub mtime_ms: Option<i64>,
103}
104
105#[derive(Debug, Deserialize)]
106pub struct PersonalityPutBody {
107 pub content: String,
108 #[serde(default)]
112 pub expected_mtime_ms: Option<i64>,
113}
114
115#[derive(Debug, Serialize)]
116pub struct PersonalityPutResponse {
117 pub bytes_written: u64,
118 pub mtime_ms: Option<i64>,
119}
120
121#[derive(Debug, Serialize)]
122pub struct PersonalityConflict {
123 pub error: &'static str,
124 pub filename: String,
125 pub current_content: String,
126 pub current_mtime_ms: Option<i64>,
127}
128
129fn validate_filename(
132 filename: &str,
133) -> Result<&'static str, (StatusCode, Json<serde_json::Value>)> {
134 EDITABLE_PERSONALITY_FILES
135 .iter()
136 .copied()
137 .find(|allowed| *allowed == filename)
138 .ok_or_else(|| {
139 (
140 StatusCode::BAD_REQUEST,
141 Json(serde_json::json!({
142 "error": "filename not in personality allowlist",
143 "filename": filename,
144 "allowed": EDITABLE_PERSONALITY_FILES,
145 })),
146 )
147 })
148}
149
150fn personality_path(workspace_dir: &Path, filename: &'static str) -> PathBuf {
151 workspace_dir.join(filename)
152}
153
154fn resolve_agent_workspace(
158 state: &AppState,
159 agent: Option<&str>,
160) -> Result<PathBuf, (StatusCode, Json<serde_json::Value>)> {
161 let Some(alias) = agent.map(str::trim).filter(|s| !s.is_empty()) else {
162 return Err((
163 StatusCode::BAD_REQUEST,
164 Json(serde_json::json!({
165 "error": "missing required `agent` query parameter",
166 })),
167 ));
168 };
169 let cfg = state.config.read();
170 if !cfg.agents.contains_key(alias) {
171 return Err((
172 StatusCode::NOT_FOUND,
173 Json(serde_json::json!({
174 "error": "unknown agent alias",
175 "agent": alias,
176 })),
177 ));
178 }
179 Ok(cfg.agent_workspace_dir(alias))
180}
181
182fn mtime_ms_of(meta: &std::fs::Metadata) -> Option<i64> {
183 meta.modified()
184 .ok()
185 .and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
186 .and_then(|d| i64::try_from(d.as_millis()).ok())
187}
188
189fn truncate_to_chars(content: &str, max: usize) -> (String, bool) {
190 if content.chars().count() <= max {
191 return (content.to_string(), false);
192 }
193 let cut = content
194 .char_indices()
195 .nth(max)
196 .map(|(idx, _)| &content[..idx])
197 .unwrap_or(content);
198 (cut.to_string(), true)
199}
200
201pub async fn handle_index(
206 State(state): State<AppState>,
207 headers: HeaderMap,
208 Query(q): Query<AgentQuery>,
209) -> impl IntoResponse {
210 if let Err(e) = require_auth(&state, &headers) {
211 return e.into_response();
212 }
213
214 let workspace_dir = match resolve_agent_workspace(&state, q.agent.as_deref()) {
215 Ok(p) => p,
216 Err(resp) => return resp.into_response(),
217 };
218
219 let files: Vec<PersonalityIndexEntry> = EDITABLE_PERSONALITY_FILES
220 .iter()
221 .copied()
222 .map(|filename| {
223 let path = workspace_dir.join(filename);
224 match std::fs::metadata(&path) {
225 Ok(meta) => PersonalityIndexEntry {
226 filename,
227 exists: meta.is_file(),
228 size: meta.len(),
229 mtime_ms: mtime_ms_of(&meta),
230 },
231 Err(_) => PersonalityIndexEntry {
232 filename,
233 exists: false,
234 size: 0,
235 mtime_ms: None,
236 },
237 }
238 })
239 .collect();
240
241 Json(PersonalityIndex {
242 files,
243 max_chars: MAX_FILE_CHARS,
244 })
245 .into_response()
246}
247
248pub async fn handle_get(
250 State(state): State<AppState>,
251 headers: HeaderMap,
252 axum::extract::Path(filename): axum::extract::Path<String>,
253 Query(q): Query<AgentQuery>,
254) -> impl IntoResponse {
255 if let Err(e) = require_auth(&state, &headers) {
256 return e.into_response();
257 }
258
259 let allowed = match validate_filename(&filename) {
260 Ok(f) => f,
261 Err(e) => return e.into_response(),
262 };
263
264 let workspace_dir = match resolve_agent_workspace(&state, q.agent.as_deref()) {
265 Ok(p) => p,
266 Err(resp) => return resp.into_response(),
267 };
268 let path = personality_path(&workspace_dir, allowed);
269
270 match std::fs::read_to_string(&path) {
271 Ok(raw) => {
272 let (content, truncated) = truncate_to_chars(&raw, MAX_FILE_CHARS);
273 let mtime_ms = std::fs::metadata(&path).ok().and_then(|m| mtime_ms_of(&m));
274 Json(PersonalityFileResponse {
275 filename: allowed.to_string(),
276 content,
277 exists: true,
278 truncated,
279 mtime_ms,
280 })
281 .into_response()
282 }
283 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Json(PersonalityFileResponse {
284 filename: allowed.to_string(),
285 content: String::new(),
286 exists: false,
287 truncated: false,
288 mtime_ms: None,
289 })
290 .into_response(),
291 Err(err) => (
292 StatusCode::INTERNAL_SERVER_ERROR,
293 Json(serde_json::json!({
294 "error": "failed to read personality file",
295 "filename": allowed,
296 "detail": err.to_string(),
297 })),
298 )
299 .into_response(),
300 }
301}
302
303pub async fn handle_put(
305 State(state): State<AppState>,
306 headers: HeaderMap,
307 axum::extract::Path(filename): axum::extract::Path<String>,
308 Query(q): Query<AgentQuery>,
309 Json(body): Json<PersonalityPutBody>,
310) -> impl IntoResponse {
311 if let Err(e) = require_auth(&state, &headers) {
312 return e.into_response();
313 }
314
315 let allowed = match validate_filename(&filename) {
316 Ok(f) => f,
317 Err(e) => return e.into_response(),
318 };
319
320 if body.content.chars().count() > MAX_FILE_CHARS {
321 return (
322 StatusCode::PAYLOAD_TOO_LARGE,
323 Json(serde_json::json!({
324 "error": "content exceeds MAX_FILE_CHARS",
325 "max_chars": MAX_FILE_CHARS,
326 })),
327 )
328 .into_response();
329 }
330
331 let workspace_dir = match resolve_agent_workspace(&state, q.agent.as_deref()) {
332 Ok(p) => p,
333 Err(resp) => return resp.into_response(),
334 };
335 let path = personality_path(&workspace_dir, allowed);
336
337 if let Some(expected) = body.expected_mtime_ms {
340 let current = std::fs::metadata(&path).ok().and_then(|m| mtime_ms_of(&m));
341 if current != Some(expected) {
342 let current_content = std::fs::read_to_string(&path).unwrap_or_default();
343 return (
344 StatusCode::CONFLICT,
345 Json(PersonalityConflict {
346 error: "personality_disk_drift",
347 filename: allowed.to_string(),
348 current_content,
349 current_mtime_ms: current,
350 }),
351 )
352 .into_response();
353 }
354 }
355
356 if let Some(parent) = path.parent()
357 && let Err(err) = std::fs::create_dir_all(parent)
358 {
359 return (
360 StatusCode::INTERNAL_SERVER_ERROR,
361 Json(serde_json::json!({
362 "error": "failed to create workspace dir",
363 "detail": err.to_string(),
364 })),
365 )
366 .into_response();
367 }
368
369 if let Err(err) = std::fs::write(&path, body.content.as_bytes()) {
370 return (
371 StatusCode::INTERNAL_SERVER_ERROR,
372 Json(serde_json::json!({
373 "error": "failed to write personality file",
374 "filename": allowed,
375 "detail": err.to_string(),
376 })),
377 )
378 .into_response();
379 }
380
381 let meta = std::fs::metadata(&path).ok();
382 let bytes_written = meta.as_ref().map(|m| m.len()).unwrap_or(0);
383 let mtime_ms = meta.as_ref().and_then(mtime_ms_of);
384
385 Json(PersonalityPutResponse {
386 bytes_written,
387 mtime_ms,
388 })
389 .into_response()
390}
391
392pub async fn handle_templates(
400 State(state): State<AppState>,
401 headers: HeaderMap,
402 Query(q): Query<TemplateQuery>,
403) -> impl IntoResponse {
404 if let Err(e) = require_auth(&state, &headers) {
405 return e.into_response();
406 }
407
408 let (memory_default_enabled, agent_display_default) = {
409 let cfg = state.config.read();
410 let mem = cfg.memory.backend.as_str() != "none";
411 let display = q
412 .agent
413 .as_deref()
414 .map(str::to_string)
415 .filter(|alias| cfg.agents.contains_key(alias));
416 (mem, display)
417 };
418
419 let defaults = TemplateContext::default();
420 let ctx = TemplateContext {
421 agent: q
422 .agent_name
423 .or(agent_display_default)
424 .unwrap_or(defaults.agent),
425 user: q.user_name.unwrap_or(defaults.user),
426 timezone: q.timezone.unwrap_or(defaults.timezone),
427 communication_style: q
428 .communication_style
429 .unwrap_or(defaults.communication_style),
430 include_memory: q.include_memory.unwrap_or(memory_default_enabled),
431 };
432
433 let files = render_preset_default(&ctx)
434 .into_iter()
435 .map(|(filename, content)| TemplateFile { filename, content })
436 .collect();
437
438 Json(TemplateResponse {
439 preset: "default",
440 files,
441 })
442 .into_response()
443}
444
445#[cfg(test)]
446mod tests {
447 use super::*;
448
449 #[test]
450 fn validate_filename_accepts_allowlist() {
451 for f in EDITABLE_PERSONALITY_FILES {
452 assert!(validate_filename(f).is_ok());
453 }
454 }
455
456 #[test]
457 fn validate_filename_rejects_traversal() {
458 for bad in [
459 "../etc/passwd",
460 "IDENTITY.md/foo",
461 "OTHER.md",
462 "identity.md", "",
464 ] {
465 assert!(validate_filename(bad).is_err());
466 }
467 }
468
469 #[test]
470 fn personality_path_joins_workspace_root() {
471 let p = personality_path(Path::new("/tmp/ws"), "SOUL.md");
472 assert_eq!(p, Path::new("/tmp/ws/SOUL.md"));
473 }
474
475 #[test]
476 fn truncate_at_max_chars() {
477 let s = "x".repeat(MAX_FILE_CHARS + 100);
478 let (out, trunc) = truncate_to_chars(&s, MAX_FILE_CHARS);
479 assert!(trunc);
480 assert_eq!(out.chars().count(), MAX_FILE_CHARS);
481 }
482
483 #[test]
484 fn no_truncation_when_under_limit() {
485 let (out, trunc) = truncate_to_chars("hello", MAX_FILE_CHARS);
486 assert!(!trunc);
487 assert_eq!(out, "hello");
488 }
489}