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
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user