Skip to main content

zeroclaw_gateway/
api_personality.rs

1//! Read/write endpoints for per-agent personality markdown files
2//! (`SOUL.md`, `IDENTITY.md`, `USER.md`, `AGENTS.md`, `TOOLS.md`,
3//! `HEARTBEAT.md`, `BOOTSTRAP.md`, `MEMORY.md`).
4//!
5//! The runtime injects these into the system prompt at request time
6//! (see `zeroclaw_runtime::agent::personality::load_personality`). This
7//! module is the dashboard's authoring surface for them.
8//!
9//! Sandbox: filenames are matched against the static `EDITABLE_PERSONALITY_FILES`
10//! allowlist re-exported from the runtime crate. The on-disk path is
11//! built from a `&'static str` taken from that allowlist plus the
12//! agent's workspace dir resolved via `Config::agent_workspace_dir`,
13//! so user-supplied path components cannot escape the workspace.
14//!
15//! The `agent` query parameter is required and selects which agent's
16//! workspace the endpoint operates against. Each agent has its own
17//! `<install>/agents/<alias>/workspace/` per the multi-agent layout.
18
19use 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// ── HTTP-specific request/response shapes (not shared) ──────────────
39
40#[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
79// ── Sandbox helpers ─────────────────────────────────────────────────
80
81fn 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
104/// Resolve the per-agent workspace directory for personality I/O. Returns
105/// an error response when `agent` is missing or unknown so callers can
106/// short-circuit before touching disk.
107fn 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
151// ── Handlers ────────────────────────────────────────────────────────
152
153/// GET /api/personality?agent=`<alias>` — index of all allowlist files in the
154/// named agent's workspace.
155pub 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
198/// GET /api/personality/{filename} — read one file's full content.
199pub 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
253/// PUT /api/personality/{filename} — overwrite the file.
254pub 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    // Disk-drift guard: if the editor told us what mtime it saw, reject
288    // the write when disk has moved since.
289    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
342/// GET /api/personality/templates — render the default starter set.
343///
344/// Reuses `TemplateContext::default()` for any field the caller didn't
345/// override. The `memory.backend` config is consulted as a sensible
346/// default for `include_memory` when the query parameter is absent, so
347/// onboarding picks the right MEMORY.md behaviour without the user
348/// having to repeat themselves.
349pub 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", // case-sensitive on purpose; matches runtime
416            "",
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}