Add web terminal, git credentials, OpenSCAD, review loops, automations
- Web terminal (PTY over WebSocket, xterm.js, sandboxed, reattach with replay) - Per-user git credentials and commit identity (credential store in the user's home) - OpenSCAD tool (STL/3MF + preview renders, bounding box) and cad skill; 3D model card - Craft phase inferred from tool activity when the agent does not report it - Configurable review loop per mode (rounds, reviewer model, criteria) - Delete config and Markdown agents from the agent editor - Automations: cron-scheduled agents from Markdown job files, schedule tool, page - change_directory tool (only on the user's request) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a0d0e707f2
commit
df1924b0a1
41 files changed
+2809
-34
No files matched your search
@@ -68,6 +68,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/providers/reload", post(reload_providers))
|
||||
.route("/models", get(models))
|
||||
.route("/agents", get(agents))
|
||||
.route("/agents/{name}", delete(delete_agent))
|
||||
.route("/commands", get(commands))
|
||||
.route("/sessions", get(list_sessions).post(create_session))
|
||||
.route("/sessions/search", get(search_sessions))
|
||||
@@ -121,6 +122,13 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/images/config", get(images_config))
|
||||
.route("/images/{id}", delete(delete_image))
|
||||
.route("/images/{id}/file", get(image_file))
|
||||
.route("/account/git", get(get_git_settings).put(put_git_settings))
|
||||
.route("/jobs", get(list_jobs).post(register_job))
|
||||
.route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job))
|
||||
.route("/jobs/{id}/run", post(run_job))
|
||||
.route("/terminals", get(list_terminals).post(open_terminal))
|
||||
.route("/terminals/{id}", delete(close_terminal))
|
||||
.route("/terminals/{id}/ws", get(terminal_ws))
|
||||
.route("/push/key", get(push_key))
|
||||
.route("/push/subscribe", post(push_subscribe).delete(push_unsubscribe))
|
||||
.route("/push/test", post(push_test))
|
||||
@@ -238,6 +246,35 @@ async fn agents(Extension(user): Extension<User>, State(app): State<AppState>, Q
|
||||
Json(json!(prompts::agents(&app, &root, &cwd)))
|
||||
}
|
||||
|
||||
/// Delete a user-defined agent: its config entry and/or its Markdown file.
|
||||
async fn delete_agent(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>, Query(q): Query<CwdQuery>) -> ApiResult {
|
||||
let (root, cwd) = scope(&app, &user, q.cwd);
|
||||
let agent = prompts::agents(&app, &root, &cwd).into_iter().find(|a| a.name == name).ok_or_else(|| not_found("agent"))?;
|
||||
let mut removed = Vec::new();
|
||||
if app.config().agents.contains_key(&name) {
|
||||
require_admin(&user)?;
|
||||
let patch = ConfigPatch { path: vec!["agents".to_string(), name.clone()], value: Value::Null };
|
||||
app.patch_config(&[patch]).await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
removed.push("config".to_string());
|
||||
}
|
||||
if let Some(path) = &agent.path {
|
||||
let p = std::path::PathBuf::from(path);
|
||||
// Global agent files are admin-managed; project files must be in the user's folders.
|
||||
let in_data = p.starts_with(app.data_dir());
|
||||
let allowed = if in_data { user.is_admin() } else { crate::files::checked_path(&app, &user, path, true).is_ok() };
|
||||
if !allowed || p.starts_with(prompts::builtin_resources()) {
|
||||
return Err(ApiError(StatusCode::FORBIDDEN, "this agent file cannot be deleted by you".into()));
|
||||
}
|
||||
std::fs::remove_file(&p)?;
|
||||
removed.push(path.clone());
|
||||
}
|
||||
if removed.is_empty() {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "built-in modes cannot be deleted".into()));
|
||||
}
|
||||
app.audit(Some(&user), "agent.delete", format!("{name}: {}", removed.join(", ")), "");
|
||||
Ok(Json(json!({"ok": true, "removed": removed})))
|
||||
}
|
||||
|
||||
async fn commands(Extension(user): Extension<User>, State(app): State<AppState>, Query(q): Query<CwdQuery>) -> Json<Value> {
|
||||
let (root, cwd) = scope(&app, &user, q.cwd);
|
||||
Json(json!({
|
||||
@@ -1009,6 +1046,109 @@ async fn stats(Extension(user): Extension<User>, State(app): State<AppState>, Qu
|
||||
|
||||
// --- websocket --------------------------------------------------------------
|
||||
|
||||
// --- automations (scheduled agents) ------------------------------------------------
|
||||
|
||||
fn own_job(app: &App, user: &User, id: &str) -> Result<crate::jobs::Job, ApiError> {
|
||||
let job = app.store.job(id)?.ok_or_else(|| not_found("automation"))?;
|
||||
if job.owner_id != user.id && !user.is_admin() {
|
||||
return Err(forbidden());
|
||||
}
|
||||
Ok(job)
|
||||
}
|
||||
|
||||
async fn list_jobs(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let jobs: Vec<Value> = app.store.jobs(Some(&user.id))?.iter().map(crate::jobs::describe).collect();
|
||||
Ok(Json(json!(jobs)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RegisterJob {
|
||||
path: String,
|
||||
}
|
||||
|
||||
async fn register_job(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<RegisterJob>) -> ApiResult {
|
||||
let p = crate::files::checked_path(&app, &user, &b.path, true)?;
|
||||
crate::jobs::parse(&p).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
let job = app.store.job_register(&user.id, &p.display().to_string())?;
|
||||
app.audit(Some(&user), "job.register", &job.path, "");
|
||||
Ok(Json(crate::jobs::describe(&job)))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct UpdateJob {
|
||||
enabled: Option<bool>,
|
||||
}
|
||||
|
||||
async fn update_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(b): Json<UpdateJob>) -> ApiResult {
|
||||
let job = own_job(&app, &user, &id)?;
|
||||
if let Some(e) = b.enabled {
|
||||
app.store.job_set_enabled(&job.id, e)?;
|
||||
}
|
||||
let job = app.store.job(&id)?.ok_or_else(|| not_found("automation"))?;
|
||||
Ok(Json(crate::jobs::describe(&job)))
|
||||
}
|
||||
|
||||
async fn delete_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let job = own_job(&app, &user, &id)?;
|
||||
app.store.job_delete(&job.id)?;
|
||||
app.audit(Some(&user), "job.delete", &job.path, "");
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn run_job(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let job = own_job(&app, &user, &id)?;
|
||||
let sid = crate::jobs::run(&app, &job, "manual").await.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
Ok(Json(json!({"session_id": sid})))
|
||||
}
|
||||
|
||||
// --- git credentials ---------------------------------------------------------------
|
||||
|
||||
async fn get_git_settings(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let s: crate::gitcreds::GitSettings = app.store.user_setting(&user.id, "git")?;
|
||||
Ok(Json(json!(crate::gitcreds::masked(&s))))
|
||||
}
|
||||
|
||||
async fn put_git_settings(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<crate::gitcreds::GitSettings>) -> ApiResult {
|
||||
let old: crate::gitcreds::GitSettings = app.store.user_setting(&user.id, "git")?;
|
||||
let new = crate::gitcreds::merge(&old, b);
|
||||
crate::gitcreds::write_files(&app, &user, &new).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
|
||||
app.store.set_user_setting(&user.id, "git", &new)?;
|
||||
let hosts: Vec<&str> = new.credentials.iter().map(|c| c.host.as_str()).collect();
|
||||
app.audit(Some(&user), "git.credentials", hosts.join(", "), &crate::auth::client_ip(&headers));
|
||||
Ok(Json(json!(crate::gitcreds::masked(&new))))
|
||||
}
|
||||
|
||||
// --- web terminal ------------------------------------------------------------------
|
||||
|
||||
async fn list_terminals(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
Ok(Json(json!(app.terminals.list(&user.id))))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OpenTerminal {
|
||||
cwd: Option<String>,
|
||||
cols: Option<u16>,
|
||||
rows: Option<u16>,
|
||||
}
|
||||
|
||||
async fn open_terminal(Extension(user): Extension<User>, State(app): State<AppState>, headers: axum::http::HeaderMap, Json(b): Json<OpenTerminal>) -> ApiResult {
|
||||
let cwd = validate_cwd(&app, &user, b.cwd)?;
|
||||
let term = crate::terminal::spawn(&app, &user, &cwd, b.cols.unwrap_or(100), b.rows.unwrap_or(30)).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
app.audit(Some(&user), "terminal.open", cwd.display().to_string(), &crate::auth::client_ip(&headers));
|
||||
Ok(Json(term.info()))
|
||||
}
|
||||
|
||||
async fn close_terminal(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
Ok(Json(json!({"ok": app.terminals.kill(&id, &user.id)})))
|
||||
}
|
||||
|
||||
async fn terminal_ws(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, upgrade: WebSocketUpgrade) -> Response {
|
||||
match app.terminals.get(&id, &user.id) {
|
||||
Some(term) => upgrade.on_upgrade(move |socket| crate::terminal::attach(term, socket)),
|
||||
None => (StatusCode::NOT_FOUND, "terminal not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- web push -------------------------------------------------------------------
|
||||
|
||||
async fn push_key(State(app): State<AppState>) -> ApiResult {
|
||||
|
||||
Reference in new issue
Block a user