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 v0.8.0 multi-agent
18//! layout.
19
20use 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// ── Request / response shapes ───────────────────────────────────────
36
37#[derive(Debug, Deserialize, Default)]
38pub struct AgentQuery {
39    /// Agent alias selecting which `agents/<alias>/workspace/` the
40    /// endpoint operates against. Required for read/write/index.
41    #[serde(default)]
42    pub agent: Option<String>,
43}
44
45#[derive(Debug, Deserialize, Default)]
46pub struct TemplateQuery {
47    /// Preset name. Only `default` is recognised today; unknown values
48    /// fall through to the default preset rather than 400-ing.
49    #[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    /// When `false`, MEMORY.md is omitted and AGENTS.md is rendered for
60    /// a memory-disabled workspace.
61    #[serde(default)]
62    pub include_memory: Option<bool>,
63    /// Agent alias for which the template is being rendered. When
64    /// provided and present in `[agents]`, the agent's display name is
65    /// folded into the template context as the default for `agent_name`.
66    #[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    /// Last `mtime_ms` the editor saw via GET. When provided and the
109    /// on-disk mtime differs, the server returns 409 with the current
110    /// content + mtime so the editor can resolve the conflict.
111    #[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
129// ── Sandbox helpers ─────────────────────────────────────────────────
130
131fn 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
154/// Resolve the per-agent workspace directory for personality I/O. Returns
155/// an error response when `agent` is missing or unknown so callers can
156/// short-circuit before touching disk.
157fn 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
201// ── Handlers ────────────────────────────────────────────────────────
202
203/// GET /api/personality?agent=<alias> — index of all allowlist files in the
204/// named agent's workspace.
205pub 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
248/// GET /api/personality/{filename} — read one file's full content.
249pub 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
303/// PUT /api/personality/{filename} — overwrite the file.
304pub 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    // Disk-drift guard: if the editor told us what mtime it saw, reject
338    // the write when disk has moved since.
339    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
392/// GET /api/personality/templates — render the default starter set.
393///
394/// Reuses `TemplateContext::default()` for any field the caller didn't
395/// override. The `memory.backend` config is consulted as a sensible
396/// default for `include_memory` when the query parameter is absent, so
397/// onboarding picks the right MEMORY.md behaviour without the user
398/// having to repeat themselves.
399pub 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", // case-sensitive on purpose; matches runtime
463            "",
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}