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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-08 22:00:38 +01:00
1 parent a0d0e707f2
commit df1924b0a1
41 files changed
+2809 -34

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
//! Per-user git credentials and commit identity.
//!
//! Stored in the database and rendered into the user's home directory
//! (`/data/users/<name>/home`), which is the home of their sandboxed agents,
//! terminals and git client: `.git-credentials` (credential store) and
//! `.gitconfig-codecraft`, included from `.gitconfig` so the user's own git
//! configuration stays untouched.
use crate::app::App;
use crate::store::User;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GitCredential {
/// Host, optionally with a path prefix (e.g. "git.example.com" or "github.com/org").
pub host: String,
pub username: String,
/// Password or API token. Empty in updates = keep the stored one.
#[serde(default)]
pub token: String,
/// "https" (default) or "http".
#[serde(default)]
pub protocol: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GitSettings {
#[serde(default)]
pub name: String,
#[serde(default)]
pub email: String,
#[serde(default)]
pub credentials: Vec<GitCredential>,
}
pub fn home_of(app: &App, user: &User) -> PathBuf {
app.data_dir().join("users").join(&user.username).join("home")
}
/// Global git config for processes that run outside the sandbox (admins).
pub fn global_config(app: &App, user: &User) -> Option<PathBuf> {
let p = home_of(app, user).join(".gitconfig");
p.exists().then_some(p)
}
fn enc(s: &str) -> String {
let mut out = String::new();
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn quote(s: &str) -> String {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
/// Write the credential store and config into the user's home.
pub fn write_files(app: &App, user: &User, settings: &GitSettings) -> Result<()> {
let home = home_of(app, user);
std::fs::create_dir_all(&home)?;
let creds_path = home.join(".git-credentials");
let mut creds = String::new();
for c in &settings.credentials {
if c.host.trim().is_empty() || c.token.is_empty() {
continue;
}
let proto = if c.protocol == "http" { "http" } else { "https" };
let (host, path) = match c.host.trim().trim_start_matches("https://").trim_start_matches("http://").split_once('/') {
Some((h, p)) => (h.to_string(), format!("/{}", p.trim_matches('/'))),
None => (c.host.trim().trim_start_matches("https://").trim_start_matches("http://").to_string(), String::new()),
};
creds.push_str(&format!("{proto}://{}:{}@{host}{path}\n", enc(c.username.trim()), enc(&c.token)));
}
std::fs::write(&creds_path, creds)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&creds_path, std::fs::Permissions::from_mode(0o600))?;
}
// Managed part of the config. Two helpers: the absolute path works outside
// the sandbox, the default (~/.git-credentials) inside it.
let mut cfg = String::from("# Managed by CodeCraft (Settings → Account → Git). Do not edit.\n");
if !settings.name.trim().is_empty() || !settings.email.trim().is_empty() {
cfg.push_str("[user]\n");
if !settings.name.trim().is_empty() {
cfg.push_str(&format!("\tname = {}\n", quote(settings.name.trim())));
}
if !settings.email.trim().is_empty() {
cfg.push_str(&format!("\temail = {}\n", quote(settings.email.trim())));
}
}
cfg.push_str(&format!("[credential]\n\thelper = {}\n\thelper = store\n", quote(&format!("store --file={}", creds_path.display()))));
// Matching credentials by path needs useHttpPath for entries with a path.
if settings.credentials.iter().any(|c| c.host.trim().trim_start_matches("https://").contains('/')) {
cfg.push_str("\tuseHttpPath = true\n");
}
std::fs::write(home.join(".gitconfig-codecraft"), cfg)?;
let main = home.join(".gitconfig");
let current = std::fs::read_to_string(&main).unwrap_or_default();
if !current.contains(".gitconfig-codecraft") {
let mut next = current;
if !next.is_empty() && !next.ends_with('\n') {
next.push('\n');
}
next.push_str("[include]\n\tpath = .gitconfig-codecraft\n");
std::fs::write(&main, next)?;
}
Ok(())
}
/// Settings with tokens masked for the UI.
pub fn masked(settings: &GitSettings) -> GitSettings {
let mut s = settings.clone();
for c in &mut s.credentials {
c.token = if c.token.is_empty() { String::new() } else { "••••••••".into() };
}
s
}
/// Merge an update from the UI: empty or masked tokens keep the stored token.
pub fn merge(old: &GitSettings, mut new: GitSettings) -> GitSettings {
for c in &mut new.credentials {
if c.token.is_empty() || c.token.chars().all(|ch| ch == '•') {
if let Some(prev) = old.credentials.iter().find(|p| p.host == c.host && p.username == c.username) {
c.token = prev.token.clone();
}
}
}
new
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_masked_tokens() {
let old = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "secret".into(), protocol: String::new() }], ..Default::default() };
let upd = GitSettings { credentials: vec![GitCredential { host: "git.x".into(), username: "me".into(), token: "••••••••".into(), protocol: String::new() }], ..Default::default() };
assert_eq!(merge(&old, upd).credentials[0].token, "secret");
assert_eq!(enc("a@b:c/d"), "a%40b%3Ac%2Fd");
}
}