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