Skip to main content

zeroclaw_gateway/
api_browse.rs

1//! HTTP adapter over `zeroclaw_runtime::browse::list_directory`.
2//!
3//! `GET /api/browse?path=<relative-to-shared>` returns one level of
4//! children. All walking, containment, and sorting lives in the runtime
5//! browse module; this is request shape → service call → response shape.
6
7use axum::{
8    Json,
9    extract::{Path as AxumPath, Query, State},
10    http::{HeaderMap, StatusCode},
11    response::{IntoResponse, Response},
12};
13use base64::Engine;
14use serde::{Deserialize, Serialize};
15use zeroclaw_runtime::browse::{
16    BrowseEntry, BrowseError, delete_agent_workspace_path, list_agent_workspace, list_directory,
17    make_agent_workspace_directory, make_directory, move_agent_workspace_path,
18    read_agent_workspace_file, remove_directory,
19};
20
21use super::AppState;
22use super::api::require_auth;
23
24#[derive(Debug, Deserialize, Default)]
25pub struct BrowseQuery {
26    /// Path relative to `<install>/shared/`. Empty / unset = shared/ root.
27    #[serde(default)]
28    pub path: Option<String>,
29}
30
31#[derive(Debug, Serialize)]
32pub struct BrowseResponse {
33    pub path: String,
34    pub entries: Vec<BrowseEntry>,
35}
36
37/// `GET /api/browse?path=<relative-to-shared>`
38pub async fn handle_browse(
39    State(state): State<AppState>,
40    headers: HeaderMap,
41    Query(q): Query<BrowseQuery>,
42) -> Response {
43    if let Err(e) = require_auth(&state, &headers) {
44        return e.into_response();
45    }
46    let config = state.config.read().clone();
47    let raw = q.path.unwrap_or_default();
48    match list_directory(&config, &raw) {
49        Ok(result) => Json(BrowseResponse {
50            path: result.path,
51            entries: result.entries,
52        })
53        .into_response(),
54        Err(err) => browse_error_response(err),
55    }
56}
57
58fn browse_error_response(err: BrowseError) -> Response {
59    let status = match &err {
60        BrowseError::Escape(_) => StatusCode::BAD_REQUEST,
61        BrowseError::NotFound(_) => StatusCode::NOT_FOUND,
62        BrowseError::NotADirectory(_) => StatusCode::BAD_REQUEST,
63        BrowseError::Protected(_) => StatusCode::FORBIDDEN,
64        BrowseError::ProtectedFile(_) => StatusCode::FORBIDDEN,
65        BrowseError::TooLarge(_, _) => StatusCode::PAYLOAD_TOO_LARGE,
66        BrowseError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR,
67    };
68    (
69        status,
70        Json(serde_json::json!({ "error": format!("{}", err) })),
71    )
72        .into_response()
73}
74
75#[derive(Debug, Deserialize)]
76pub struct BrowsePathBody {
77    pub path: String,
78}
79
80/// `POST /api/browse/mkdir` — create a directory under `<install>/shared/`.
81pub async fn handle_browse_mkdir(
82    State(state): State<AppState>,
83    headers: HeaderMap,
84    Json(body): Json<BrowsePathBody>,
85) -> Response {
86    if let Err(e) = require_auth(&state, &headers) {
87        return e.into_response();
88    }
89    let config = state.config.read().clone();
90    match make_directory(&config, &body.path) {
91        Ok(()) => Json(serde_json::json!({ "created": body.path })).into_response(),
92        Err(err) => browse_error_response(err),
93    }
94}
95
96/// `DELETE /api/browse/rmdir` — recursively remove a directory under
97/// `<install>/shared/`. Refuses protected top-level entries.
98pub async fn handle_browse_rmdir(
99    State(state): State<AppState>,
100    headers: HeaderMap,
101    Json(body): Json<BrowsePathBody>,
102) -> Response {
103    if let Err(e) = require_auth(&state, &headers) {
104        return e.into_response();
105    }
106    let config = state.config.read().clone();
107    match remove_directory(&config, &body.path) {
108        Ok(()) => Json(serde_json::json!({ "removed": body.path })).into_response(),
109        Err(err) => browse_error_response(err),
110    }
111}
112
113// ── Agent workspace ──────────────────────────────────────────────────────
114
115/// `GET /api/agents/{alias}/workspace/list?path=<rel>` — one level under
116/// `<install>/agents/{alias}/workspace/<rel>`.
117pub async fn handle_agent_workspace_list(
118    State(state): State<AppState>,
119    headers: HeaderMap,
120    AxumPath(alias): AxumPath<String>,
121    Query(q): Query<BrowseQuery>,
122) -> Response {
123    if let Err(e) = require_auth(&state, &headers) {
124        return e.into_response();
125    }
126    let config = state.config.read().clone();
127    let raw = q.path.unwrap_or_default();
128    match list_agent_workspace(&config, &alias, &raw) {
129        Ok(result) => Json(BrowseResponse {
130            path: result.path,
131            entries: result.entries,
132        })
133        .into_response(),
134        Err(err) => browse_error_response(err),
135    }
136}
137
138#[derive(Debug, Serialize)]
139pub struct FileReadResponse {
140    pub path: String,
141    pub size: u64,
142    pub is_text: bool,
143    /// UTF-8 text when `is_text` is true, base64 when false. Lets the
144    /// dashboard render inline without a second round-trip for binary
145    /// previews.
146    pub content: String,
147    pub encoding: &'static str,
148}
149
150/// `GET /api/agents/{alias}/workspace/read?path=<rel>` — read a single
151/// file. Bounded by `AGENT_WORKSPACE_READ_CAP`.
152pub async fn handle_agent_workspace_read(
153    State(state): State<AppState>,
154    headers: HeaderMap,
155    AxumPath(alias): AxumPath<String>,
156    Query(q): Query<BrowseQuery>,
157) -> Response {
158    if let Err(e) = require_auth(&state, &headers) {
159        return e.into_response();
160    }
161    let config = state.config.read().clone();
162    let raw = q.path.unwrap_or_default();
163    match read_agent_workspace_file(&config, &alias, &raw) {
164        Ok(result) => {
165            let (content, encoding) = if result.is_text {
166                (String::from_utf8(result.bytes).unwrap_or_default(), "utf8")
167            } else {
168                (
169                    base64::engine::general_purpose::STANDARD.encode(&result.bytes),
170                    "base64",
171                )
172            };
173            Json(FileReadResponse {
174                path: result.path,
175                size: result.size,
176                is_text: result.is_text,
177                content,
178                encoding,
179            })
180            .into_response()
181        }
182        Err(err) => browse_error_response(err),
183    }
184}
185
186/// `DELETE /api/agents/{alias}/workspace/path` body `{ path: "<rel>" }`.
187pub async fn handle_agent_workspace_delete(
188    State(state): State<AppState>,
189    headers: HeaderMap,
190    AxumPath(alias): AxumPath<String>,
191    Json(body): Json<BrowsePathBody>,
192) -> Response {
193    if let Err(e) = require_auth(&state, &headers) {
194        return e.into_response();
195    }
196    let config = state.config.read().clone();
197    match delete_agent_workspace_path(&config, &alias, &body.path) {
198        Ok(()) => Json(serde_json::json!({ "removed": body.path })).into_response(),
199        Err(err) => browse_error_response(err),
200    }
201}
202
203#[derive(Debug, Deserialize)]
204pub struct MoveBody {
205    pub from: String,
206    pub to: String,
207}
208
209/// `POST /api/agents/{alias}/workspace/move` body `{ from, to }`.
210pub async fn handle_agent_workspace_move(
211    State(state): State<AppState>,
212    headers: HeaderMap,
213    AxumPath(alias): AxumPath<String>,
214    Json(body): Json<MoveBody>,
215) -> Response {
216    if let Err(e) = require_auth(&state, &headers) {
217        return e.into_response();
218    }
219    let config = state.config.read().clone();
220    match move_agent_workspace_path(&config, &alias, &body.from, &body.to) {
221        Ok(()) => Json(serde_json::json!({ "from": body.from, "to": body.to })).into_response(),
222        Err(err) => browse_error_response(err),
223    }
224}
225
226/// `POST /api/agents/{alias}/workspace/mkdir` body `{ path: "<rel>" }`.
227pub async fn handle_agent_workspace_mkdir(
228    State(state): State<AppState>,
229    headers: HeaderMap,
230    AxumPath(alias): AxumPath<String>,
231    Json(body): Json<BrowsePathBody>,
232) -> Response {
233    if let Err(e) = require_auth(&state, &headers) {
234        return e.into_response();
235    }
236    let config = state.config.read().clone();
237    match make_agent_workspace_directory(&config, &alias, &body.path) {
238        Ok(()) => Json(serde_json::json!({ "created": body.path })).into_response(),
239        Err(err) => browse_error_response(err),
240    }
241}