Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81d9df4e6e | ||
|
|
ead75e78e0 | ||
|
|
77f9d4b0e0 | ||
|
|
103132a359 | ||
|
|
5d5c0f8124 | ||
|
|
f77b101847 | ||
|
|
12a3198e77 | ||
|
|
4e6f74d477 | ||
|
|
658148ada7 | ||
|
|
b6e4500233 | ||
|
|
126c7719b9 | ||
|
|
fccaee4b94 | ||
|
|
d58b23d35f | ||
|
|
6e8e739906 | ||
|
|
247ddcf14a | ||
|
|
f0cd404f59 | ||
|
|
ec95f9b8ad | ||
|
|
99ba3835f3 | ||
|
|
92a99c6a65 | ||
|
|
4c7d67cabf |
No files matched your search
@@ -3,6 +3,85 @@
|
||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||
until 1.0, minor versions may contain breaking changes.
|
||||
|
||||
## [v202611-00039] – 2026-11-15
|
||||
|
||||
### Changed
|
||||
|
||||
- A chat's colour no longer tints the chat background (pane, message field, agent bubbles, top bar
|
||||
fill); it still marks the top bar line, the list entry, the badge and your own bubbles.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Claude Code can ask questions again**: its `AskUserQuestion` was always rejected; now the question
|
||||
appears as a question card in the chat (single and multiple choice) and the answers go back to it.
|
||||
|
||||
## [v202611-00038] – 2026-11-15
|
||||
|
||||
### Changed
|
||||
|
||||
- The chat menu in the sidebar has a **Delete** entry (with the question about the chat's own folder);
|
||||
colour and icon selection moved out of it – they remain in the ⋯ menu inside the chat.
|
||||
|
||||
## [v202611-00037] – 2026-11-15
|
||||
|
||||
### Added
|
||||
|
||||
- **Deleting a chat asks about its folder**: if the chat has a folder of its own (inside your folders,
|
||||
not shared with any other chat, no project folder), you can delete it with the chat – with the
|
||||
number of files, size and a warning for git repositories. The server checks again before deleting.
|
||||
|
||||
## [v202611-00036] – 2026-11-15
|
||||
|
||||
### Added
|
||||
|
||||
- **Claude Code gets all CodeCraft tools** (image generation, 3D, OpenSCAD, browser, scheduling,
|
||||
diagnostics, reverse engineering, skills, ask_user, the server's MCP tools …) over an MCP bridge,
|
||||
as `mcp__codecraft__<name>`. Tools the CLI has itself (read, write, edit, bash, grep …) are not
|
||||
offered twice. Calls go through the usual approval cards and rules.
|
||||
|
||||
## [v202611-00035] – 2026-11-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Creating an SSH key failed with "No user exists for uid 99": `ssh-keygen`, `ssh-keyscan` and
|
||||
`ssh-add` get the same passwd workaround as `ssh`.
|
||||
|
||||
## [v202611-00034] – 2026-11-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- `ssh`, `scp` and `sftp` failed with "No user exists for uid 99" because the container runs as a uid
|
||||
without passwd entry; the image now wraps them with nss_wrapper (also used by the key wrappers).
|
||||
|
||||
## [v202611-00033] – 2026-11-15
|
||||
|
||||
### Added
|
||||
|
||||
- **SSH keys** (Settings → Account): generate (ed25519) or import keys; agents, the terminal and the
|
||||
git panel use them automatically for `ssh`, `scp`, `sftp`, `rsync` and git over ssh. Hosts are
|
||||
trusted on first contact; the public key can be copied for the server's `authorized_keys`.
|
||||
|
||||
## [v202611-00032] – 2026-11-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Agents now know that git credentials are stored (hosts, no secrets) and no longer search the
|
||||
environment for them; the system prompt explains `git push` just works and how to read the token
|
||||
for REST calls without printing it. Claude Code runs get the user's git credentials and identity too.
|
||||
|
||||
## [v202611-00031] – 2026-11-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Images read by an agent (Read tool) are shown right away; with Claude Code they were dropped.
|
||||
|
||||
## [v202611-00030] – 2026-11-15
|
||||
|
||||
### Added
|
||||
|
||||
- **`/clear`**: the model forgets the conversation so far; the chat keeps showing it, a divider marks
|
||||
the cut. Also starts a fresh session with Claude Code.
|
||||
|
||||
## [v202611-00029] – 2026-11-15
|
||||
|
||||
### Added
|
||||
|
||||
+8
-1
@@ -85,7 +85,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential pkg-config libssl-dev cmake zlib1g-dev binutils-multiarch \
|
||||
chromium fonts-dejavu-core fonts-noto-color-emoji poppler-utils openmpt123 \
|
||||
openscad xvfb xauth \
|
||||
openssh-client rsync mc \
|
||||
openssh-client libnss-wrapper rsync mc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be
|
||||
# installed by agents: rustup component add rust-analyzer, go install …).
|
||||
@@ -98,6 +98,13 @@ COPY --from=dockercli /docker /usr/local/bin/docker
|
||||
COPY server/ghidra /app/ghidra
|
||||
# git for agent shells: commits get the co-author trailer of the model (server.commit_trailer).
|
||||
COPY --chmod=755 docker/git-coauthor /usr/local/lib/codecraft/bin/git
|
||||
# ssh, scp, sftp and the ssh-* tools (the server creates keys with ssh-keygen) also work when the container runs as a uid without passwd entry.
|
||||
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh
|
||||
COPY --chmod=755 docker/ssh-nss /usr/local/bin/scp
|
||||
COPY --chmod=755 docker/ssh-nss /usr/local/bin/sftp
|
||||
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-keygen
|
||||
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-keyscan
|
||||
COPY --chmod=755 docker/ssh-nss /usr/local/bin/ssh-add
|
||||
COPY server/resources /app/resources
|
||||
# The agent's home lives on the data volume: tools it installs in user space
|
||||
# (pip --user, pipx, rustup, npm -g, downloaded binaries) survive rebuilds.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img alt="Version v202611-00029" src="https://img.shields.io/badge/version-v202611--00029-d69e2e?style=flat-square">
|
||||
<img alt="Version v202611-00039" src="https://img.shields.io/badge/version-v202611--00039-d69e2e?style=flat-square">
|
||||
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
|
||||
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
|
||||
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# ssh, scp and sftp for containers that run as an arbitrary uid (docker-compose `user:`):
|
||||
# OpenSSH refuses to start without a passwd entry for the uid ("No user exists for uid N").
|
||||
# Without an entry, nss_wrapper provides one for this process; otherwise nothing changes.
|
||||
if ! getent passwd "$(id -u)" >/dev/null 2>&1; then
|
||||
d=${TMPDIR:-/tmp}/codecraft-nss-$(id -u)
|
||||
mkdir -p "$d" 2>/dev/null
|
||||
printf 'codecraft:x:%s:%s:CodeCraft:%s:/bin/sh\n' "$(id -u)" "$(id -g)" "${HOME:-/}" > "$d/passwd"
|
||||
printf 'codecraft:x:%s:\n' "$(id -g)" > "$d/group"
|
||||
export NSS_WRAPPER_PASSWD="$d/passwd" NSS_WRAPPER_GROUP="$d/group"
|
||||
export LD_PRELOAD="libnss_wrapper.so${LD_PRELOAD:+:$LD_PRELOAD}"
|
||||
fi
|
||||
exec "/usr/bin/$(basename "$0")" "$@"
|
||||
+153
-3
@@ -86,6 +86,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/sessions/{id}/search", get(search_in_session))
|
||||
.route("/sessions/{id}/media", get(session_media))
|
||||
.route("/sessions/{id}", get(get_session).patch(update_session).delete(delete_session))
|
||||
.route("/sessions/{id}/folder", get(session_folder))
|
||||
.route("/sessions/{id}/prompt", post(prompt))
|
||||
.route("/sessions/{id}/abort", post(abort))
|
||||
.route("/sessions/{id}/compact", post(compact))
|
||||
@@ -103,6 +104,7 @@ pub fn router(app: AppState) -> Router {
|
||||
.route("/sessions/{id}/links", get(list_links).post(create_link))
|
||||
.route("/sessions/{id}/links/{token}", delete(delete_link))
|
||||
.route("/public/chat/{token}", get(public_chat))
|
||||
.route("/mcp-bridge/{token}", post(crate::mcp_bridge::handle))
|
||||
.route("/sessions/{id}/shares", get(get_shares).put(put_shares))
|
||||
.route("/sessions/{id}/queue", get(get_queue))
|
||||
.route("/sessions/{id}/queue/flush", post(flush_queue))
|
||||
@@ -157,6 +159,8 @@ pub fn router(app: AppState) -> Router {
|
||||
.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("/account/ssh", get(get_ssh_keys).post(add_ssh_key))
|
||||
.route("/account/ssh/{id}", delete(delete_ssh_key))
|
||||
.route("/account/snippets", get(crate::cli_api::get_snippets).put(crate::cli_api::put_snippets))
|
||||
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
|
||||
.route("/account/sidebar", get(crate::cli_api::get_sidebar).put(crate::cli_api::put_sidebar))
|
||||
@@ -1053,8 +1057,83 @@ async fn put_file_tabs(Extension(user): Extension<User>, State(app): State<AppSt
|
||||
Ok(Json(json!({"ok": true})))
|
||||
}
|
||||
|
||||
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
writable(&app, &user, &id)?;
|
||||
/// The folder a chat owns: inside the owner's folders (never one of them), not a link, and no
|
||||
/// other chat works in it, below it or above it (then it belongs to a project, not to this chat).
|
||||
/// Returns the folder and the titles of chats that prevent it.
|
||||
fn own_folder(app: &App, session: &Session) -> (Option<PathBuf>, Vec<String>) {
|
||||
let Ok(dir) = std::fs::canonicalize(&session.cwd) else { return (None, vec![]) };
|
||||
if std::fs::symlink_metadata(&session.cwd).map(|m| m.file_type().is_symlink()).unwrap_or(true) || !dir.is_dir() {
|
||||
return (None, vec![]);
|
||||
}
|
||||
let roots: Vec<PathBuf> = app.user_roots(session.owner_id.as_deref()).iter().filter_map(|r| std::fs::canonicalize(r).ok()).collect();
|
||||
if !roots.iter().any(|r| dir.starts_with(r) && dir != *r) {
|
||||
return (None, vec![]);
|
||||
}
|
||||
let mut blockers = Vec::new();
|
||||
for (title, cwd) in app.store.other_session_cwds(&session.id).unwrap_or_default() {
|
||||
let Ok(other) = std::fs::canonicalize(&cwd) else { continue };
|
||||
if other.starts_with(&dir) || dir.starts_with(&other) {
|
||||
blockers.push(if title.is_empty() { "(untitled)".to_string() } else { title });
|
||||
}
|
||||
}
|
||||
if blockers.is_empty() { (Some(dir), blockers) } else { (None, blockers) }
|
||||
}
|
||||
|
||||
/// Facts for the delete dialog: does the chat have a folder of its own, and what is in it?
|
||||
async fn session_folder(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let session = writable(&app, &user, &id)?;
|
||||
let (dir, blockers) = own_folder(&app, &session);
|
||||
let Some(dir) = dir else { return Ok(Json(json!({"own": false, "shared_with": blockers}))) };
|
||||
let (files, bytes, git, truncated) = tokio::task::spawn_blocking({
|
||||
let dir = dir.clone();
|
||||
move || {
|
||||
let (mut files, mut bytes, mut seen) = (0u64, 0u64, 0u64);
|
||||
let mut stack = vec![dir.clone()];
|
||||
let mut truncated = false;
|
||||
while let Some(d) = stack.pop() {
|
||||
let Ok(rd) = std::fs::read_dir(&d) else { continue };
|
||||
for e in rd.flatten() {
|
||||
// Git's own files are not "the chat's files".
|
||||
if e.file_name() == ".git" {
|
||||
continue;
|
||||
}
|
||||
seen += 1;
|
||||
if seen > 50_000 {
|
||||
truncated = true;
|
||||
stack.clear();
|
||||
break;
|
||||
}
|
||||
let Ok(m) = e.metadata() else { continue };
|
||||
if m.is_dir() && !e.file_type().map(|t| t.is_symlink()).unwrap_or(true) {
|
||||
stack.push(e.path());
|
||||
} else if m.is_file() {
|
||||
files += 1;
|
||||
bytes += m.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
(files, bytes, dir.join(".git").exists(), truncated)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or((0, 0, false, false));
|
||||
Ok(Json(json!({"own": true, "path": dir, "files": files, "bytes": bytes, "git": git, "truncated": truncated})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DeleteQ {
|
||||
/// Also delete the chat's own folder (only if it really is one – checked again here).
|
||||
#[serde(default)]
|
||||
delete_folder: bool,
|
||||
}
|
||||
|
||||
async fn delete_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Query(q): Query<DeleteQ>) -> ApiResult {
|
||||
let session = writable(&app, &user, &id)?;
|
||||
// Decide about the folder while the chat and all the others still exist.
|
||||
let folder = if q.delete_folder { own_folder(&app, &session).0 } else { None };
|
||||
if q.delete_folder && folder.is_none() {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "this chat has no folder of its own (other chats use it, or it is a project folder)".into()));
|
||||
}
|
||||
// Notify before deleting: visibility is checked against the stored session.
|
||||
app.emit(Event::SessionDeleted { session_id: id.clone() });
|
||||
runner::abort(&app, &id);
|
||||
@@ -1068,7 +1147,20 @@ async fn delete_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
tracing::warn!("image cleanup: {e:#}");
|
||||
}
|
||||
});
|
||||
Ok(Json(json!({"ok": true})))
|
||||
let mut folder_error = None;
|
||||
let mut folder_deleted = false;
|
||||
if let Some(dir) = folder {
|
||||
let shown = dir.display().to_string();
|
||||
match tokio::task::spawn_blocking(move || std::fs::remove_dir_all(&dir)).await {
|
||||
Ok(Ok(())) => {
|
||||
folder_deleted = true;
|
||||
app.audit(Some(&user), "session.delete_folder", &shown, "");
|
||||
}
|
||||
Ok(Err(e)) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
|
||||
Err(e) => folder_error = Some(format!("the chat was deleted, but not its folder: {e}")),
|
||||
}
|
||||
}
|
||||
Ok(Json(json!({"ok": true, "folder_deleted": folder_deleted, "folder_error": folder_error})))
|
||||
}
|
||||
|
||||
/// An image of a chat message (see blobs.rs). Addressed by the SHA-256 of its bytes,
|
||||
@@ -1128,6 +1220,13 @@ pub(crate) fn send_prompt(app: &AppState, session: crate::types::Session, text:
|
||||
tokio::spawn(do_compact(app.clone(), id.clone()));
|
||||
return Ok(json!({"ok": true, "command": "compact"}));
|
||||
}
|
||||
if text == "/clear" {
|
||||
if app.is_busy(&id) {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "session is busy".into()));
|
||||
}
|
||||
runner::clear_context(app, &id).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
|
||||
return Ok(json!({"ok": true, "command": "clear"}));
|
||||
}
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||
let mut prompt_text = text.clone();
|
||||
@@ -2419,6 +2518,57 @@ async fn run_job(Extension(user): Extension<User>, State(app): State<AppState>,
|
||||
|
||||
// --- git credentials ---------------------------------------------------------------
|
||||
|
||||
// --- SSH keys -----------------------------------------------------------------
|
||||
|
||||
async fn get_ssh_keys(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||
let s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
|
||||
Ok(Json(json!(crate::sshkeys::masked(&s))))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct NewSshKey {
|
||||
name: String,
|
||||
/// An existing private key to import; a new key is generated when missing.
|
||||
private: Option<String>,
|
||||
}
|
||||
|
||||
async fn add_ssh_key(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<NewSshKey>) -> ApiResult {
|
||||
let name = b.name.trim().to_string();
|
||||
if name.is_empty() || name.chars().count() > 60 {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "give the key a name (up to 60 characters)".into()));
|
||||
}
|
||||
let mut s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
|
||||
if s.keys.len() >= 10 {
|
||||
return Err(ApiError(StatusCode::BAD_REQUEST, "at most 10 keys".into()));
|
||||
}
|
||||
let key = match b.private.as_deref().filter(|p| !p.trim().is_empty()) {
|
||||
Some(p) => crate::sshkeys::import(&name, p).await,
|
||||
None => crate::sshkeys::generate(&name).await,
|
||||
}
|
||||
.map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||
if s.keys.iter().any(|k| k.fingerprint == key.fingerprint && !key.fingerprint.is_empty()) {
|
||||
return Err(ApiError(StatusCode::CONFLICT, "this key is already stored".into()));
|
||||
}
|
||||
s.keys.push(key);
|
||||
crate::sshkeys::write_files(&app, &user, &s).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
|
||||
app.store.set_user_setting(&user.id, "ssh", &s)?;
|
||||
app.audit(Some(&user), "ssh.key.add", &name, "");
|
||||
Ok(Json(json!(crate::sshkeys::masked(&s))))
|
||||
}
|
||||
|
||||
async fn delete_ssh_key(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>) -> ApiResult {
|
||||
let mut s: crate::sshkeys::SshSettings = app.store.user_setting(&user.id, "ssh")?;
|
||||
let before = s.keys.len();
|
||||
s.keys.retain(|k| k.id != id);
|
||||
if s.keys.len() == before {
|
||||
return Err(not_found("key"));
|
||||
}
|
||||
crate::sshkeys::write_files(&app, &user, &s).map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")))?;
|
||||
app.store.set_user_setting(&user.id, "ssh", &s)?;
|
||||
app.audit(Some(&user), "ssh.key.delete", &id, "");
|
||||
Ok(Json(json!(crate::sshkeys::masked(&s))))
|
||||
}
|
||||
|
||||
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))))
|
||||
|
||||
@@ -189,6 +189,9 @@ fn is_public(path: &str) -> bool {
|
||||
// Read-only chat links (the token is the credential).
|
||||
|| path.starts_with("/public/chat/")
|
||||
|| path.starts_with("/api/public/chat/")
|
||||
// MCP bridge for the Claude Code CLI (per-run token).
|
||||
|| path.starts_with("/mcp-bridge/")
|
||||
|| path.starts_with("/api/mcp-bridge/")
|
||||
}
|
||||
|
||||
pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: Next) -> Response {
|
||||
|
||||
+145
-10
@@ -11,7 +11,7 @@ use crate::config::ModelConfig;
|
||||
use crate::permission::{Decision, Evaluator, ToolInfo, bash_prefix, is_dangerous, split_command};
|
||||
use crate::provider::{ChatRequest, Provider, StreamEvent};
|
||||
use crate::store::UsageRecord;
|
||||
use crate::types::{Event, Message, Part, Role, Session, Usage, new_id, now_ms};
|
||||
use crate::types::{Event, ImageData, Message, Part, Role, Session, Usage, new_id, now_ms};
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
@@ -97,14 +97,66 @@ fn command(app: &App, owner: Option<&str>, cwd: &Path, args: &[String]) -> Resul
|
||||
c.env(k, v);
|
||||
}
|
||||
}
|
||||
c.env("TERM", "xterm-256color").env("CODECRAFT", "1").args(args);
|
||||
c.env("TERM", "xterm-256color").env("CODECRAFT", "1").env("GIT_TERMINAL_PROMPT", "0").args(args);
|
||||
// The user's git credentials and identity (the CLI runs git for the agent).
|
||||
if let Some(user) = owner.and_then(|id| app.store.user(id).ok().flatten()) {
|
||||
if let Some(cfg) = crate::gitcreds::global_config(app, &user) {
|
||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||
}
|
||||
c.envs(crate::gitcreds::identity_env(app, &user.id));
|
||||
c.envs(crate::sshkeys::env(app, &user));
|
||||
}
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
// --- tool mapping -----------------------------------------------------------------------
|
||||
|
||||
/// Claude Code tool → (our tool name, input with our key names), so the chat renders it like ours.
|
||||
/// Claude Code's `AskUserQuestion` input → our questions (it writes `multiSelect`).
|
||||
fn cc_questions(input: &Value) -> Vec<crate::types::Question> {
|
||||
input["questions"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|q| {
|
||||
let question = q["question"].as_str()?.to_string();
|
||||
let options = q["options"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|o| Some(crate::types::QuestionOption { label: o["label"].as_str()?.to_string(), description: o["description"].as_str().unwrap_or("").to_string() }))
|
||||
.collect();
|
||||
Some(crate::types::Question { question, header: q["header"].as_str().unwrap_or("").to_string(), options, multi_select: q["multiSelect"].as_bool().or(q["multi_select"].as_bool()).unwrap_or(false) })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Our answers (question → text or list) as the CLI expects them: question → text, lists joined by ", ".
|
||||
fn cc_answers(answers: &serde_json::Map<String, Value>) -> Value {
|
||||
Value::Object(
|
||||
answers
|
||||
.iter()
|
||||
.map(|(q, a)| {
|
||||
let text = match a {
|
||||
Value::String(s) => s.clone(),
|
||||
Value::Array(l) => l.iter().filter_map(|x| x.as_str()).collect::<Vec<_>>().join(", "),
|
||||
other => other.to_string(),
|
||||
};
|
||||
(q.clone(), Value::String(text))
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn map_tool(name: &str, input: &Value) -> (String, Value) {
|
||||
// Questions to the user show up as our question card / ask_user call.
|
||||
if name == "AskUserQuestion" {
|
||||
return ("ask_user".to_string(), json!({"questions": cc_questions(input)}));
|
||||
}
|
||||
// Our own tools reach the CLI over MCP: shown and judged under their usual names.
|
||||
if let Some(ours) = name.strip_prefix(crate::mcp_bridge::PREFIX) {
|
||||
return (ours.to_string(), input.clone());
|
||||
}
|
||||
let mut v = input.clone();
|
||||
let rename = |v: &mut Value, from: &str, to: &str| {
|
||||
if let Some(x) = v.get(from).cloned() {
|
||||
@@ -141,6 +193,8 @@ impl ToolInfo for CcTool {
|
||||
}
|
||||
fn read_only(&self) -> bool {
|
||||
matches!(self.name.as_str(), "read" | "glob" | "grep" | "web_fetch" | "web_search" | "task" | "todowrite" | "list")
|
||||
// One of our tools (MCP bridge): as it declares itself.
|
||||
|| crate::tools::builtin_tools().iter().any(|t| t.name() == self.name && t.read_only())
|
||||
}
|
||||
fn is_edit(&self) -> bool {
|
||||
matches!(self.name.as_str(), "write" | "edit")
|
||||
@@ -170,16 +224,16 @@ fn title_of(name: &str, input: &Value, claude_input: &Value) -> String {
|
||||
match name {
|
||||
"bash" => input["description"].as_str().filter(|d| !d.is_empty()).map(String::from).unwrap_or_else(|| input["command"].as_str().unwrap_or("").chars().take(120).collect()),
|
||||
"write" | "edit" | "read" => input["path"].as_str().unwrap_or("").to_string(),
|
||||
_ => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()),
|
||||
_ => match crate::tools::builtin_tools().iter().find(|t| t.name() == name) {
|
||||
Some(t) => t.title(input),
|
||||
None => claude_input["description"].as_str().map(String::from).unwrap_or_else(|| name.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask our permission system whether a tool call of Claude Code may run: (allow, message for a rejection).
|
||||
async fn decide(app: &Arc<App>, session: &Session, claude_name: &str, claude_input: &Value, cancel: &CancellationToken) -> (bool, Option<String>) {
|
||||
let (name, input) = map_tool(claude_name, claude_input);
|
||||
if claude_name == "AskUserQuestion" {
|
||||
return (false, Some("Questions to the user are not available in this mode – decide yourself or state your assumption.".into()));
|
||||
}
|
||||
let info = CcTool { name: name.clone() };
|
||||
let cfg = app.config();
|
||||
let root = app.root_session_id(&session.id);
|
||||
@@ -286,6 +340,20 @@ fn result_text(content: &Value) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Images inside a tool result (the CLI's Read of a picture): base64 blocks.
|
||||
fn result_images(content: &Value) -> Vec<ImageData> {
|
||||
content
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|b| b["type"] == "image" && b["source"]["type"] == "base64")
|
||||
.filter_map(|b| {
|
||||
let data = b["source"]["data"].as_str().filter(|d| !d.is_empty())?;
|
||||
Some(ImageData { media_type: b["source"]["media_type"].as_str().unwrap_or("image/png").to_string(), data: data.to_string(), blob: None, size: None })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Images of the prompt as files the CLI can read (it has no inline image input in this mode).
|
||||
fn save_images(cwd: &str, parts: &[Part]) -> Vec<String> {
|
||||
use base64::Engine;
|
||||
@@ -343,8 +411,18 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
||||
if let Some(r) = &resume {
|
||||
args.extend(["--resume".into(), r.clone()]);
|
||||
}
|
||||
if !session.instructions.trim().is_empty() {
|
||||
args.extend(["--append-system-prompt".into(), session.instructions.trim().to_string()]);
|
||||
// CodeCraft's own tools for the CLI (MCP, per-run token).
|
||||
let bridge = crate::mcp_bridge::register(session_id);
|
||||
args.extend(["--mcp-config".into(), crate::mcp_bridge::config_json(&app, &bridge.token)]);
|
||||
let mut appended = session.instructions.trim().to_string();
|
||||
if let Some(note) = crate::mcp_bridge::prompt_section(&crate::mcp_bridge::tools_for(&app, &session)) {
|
||||
appended = format!("{appended}\n{note}").trim().to_string();
|
||||
}
|
||||
for extra in owner.as_deref().into_iter().flat_map(|u| [crate::gitcreds::prompt_section(&app, u), crate::sshkeys::prompt_section(&app, u)]).flatten() {
|
||||
appended = format!("{appended}\n{extra}").trim().to_string();
|
||||
}
|
||||
if !appended.is_empty() {
|
||||
args.extend(["--append-system-prompt".into(), appended]);
|
||||
}
|
||||
|
||||
let mut cmd = command(&app, owner.as_deref(), Path::new(&session.cwd), &args)?;
|
||||
@@ -376,6 +454,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
||||
let mut cur: Option<Cur> = None;
|
||||
let mut calls: HashMap<String, String> = HashMap::new();
|
||||
let mut diffs: HashMap<String, String> = HashMap::new();
|
||||
// Answers to AskUserQuestion calls, kept as the result's data for the question card.
|
||||
let mut asked: HashMap<String, Value> = HashMap::new();
|
||||
let mut final_text = String::new();
|
||||
let mut got_result = false;
|
||||
let mut failure: Option<String> = None;
|
||||
@@ -457,8 +537,8 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
||||
}
|
||||
let id = b["tool_use_id"].as_str().unwrap_or("").to_string();
|
||||
let is_error = b["is_error"].as_bool().unwrap_or(false);
|
||||
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})) };
|
||||
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: vec![], is_error, meta });
|
||||
let meta = if is_error { None } else { diffs.remove(&id).map(|d| json!({"diff": d})).or_else(|| asked.remove(&id).map(|a| json!({"answers": a}))) };
|
||||
parts.push(Part::ToolResult { name: calls.get(&id).cloned().unwrap_or_default(), call_id: id, content: result_text(&b["content"]), images: result_images(&b["content"]), is_error, meta });
|
||||
}
|
||||
if !parts.is_empty() {
|
||||
let m = Message::new(Role::User, parts);
|
||||
@@ -470,6 +550,30 @@ pub async fn run(app: Arc<App>, session_id: &str, cancel: CancellationToken) ->
|
||||
"control_request" if ev["request"]["subtype"] == "can_use_tool" => {
|
||||
finalize(&app, session_id, &mut cur, &model_id);
|
||||
let req = &ev["request"];
|
||||
// A question for the user: shown as a question card; the answers go back to the CLI.
|
||||
if req["tool_name"] == "AskUserQuestion" {
|
||||
let questions = cc_questions(&req["input"]);
|
||||
let response = if questions.is_empty() {
|
||||
json!({"behavior": "deny", "message": "No usable question was given."})
|
||||
} else {
|
||||
match app.ask_user(session_id, "agent", questions, cancel.clone()).await {
|
||||
Ok(Some(answers)) => {
|
||||
let mut input = req["input"].clone();
|
||||
input["answers"] = cc_answers(&answers);
|
||||
if let Some(id) = req["tool_use_id"].as_str() {
|
||||
asked.insert(id.to_string(), Value::Object(answers));
|
||||
}
|
||||
json!({"behavior": "allow", "updatedInput": input})
|
||||
}
|
||||
_ => json!({"behavior": "deny", "message": "The user dismissed the questions without answering."}),
|
||||
}
|
||||
};
|
||||
let reply = json!({"type": "control_response", "response": {"subtype": "success", "request_id": ev["request_id"], "response": response}});
|
||||
if stdin.write_all(format!("{reply}\n").as_bytes()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let (allow, message) = decide(&app, &session, req["tool_name"].as_str().unwrap_or(""), &req["input"], &cancel).await;
|
||||
if allow {
|
||||
let (name, input) = map_tool(req["tool_name"].as_str().unwrap_or(""), &req["input"]);
|
||||
@@ -852,6 +956,37 @@ pub async fn update() -> Result<String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn questions_roundtrip_with_the_cli_format() {
|
||||
let input = json!({"questions": [
|
||||
{"question": "Which database?", "header": "DB", "multiSelect": false, "options": [{"label": "SQLite", "description": "file"}, {"label": "Postgres"}]},
|
||||
{"question": "Which extras?", "header": "Extras", "multiSelect": true, "options": [{"label": "Auth"}, {"label": "Docs"}]},
|
||||
{"nonsense": true}
|
||||
]});
|
||||
let qs = cc_questions(&input);
|
||||
assert_eq!(qs.len(), 2);
|
||||
assert_eq!((qs[0].question.as_str(), qs[0].options.len(), qs[0].multi_select), ("Which database?", 2, false));
|
||||
assert!(qs[1].multi_select);
|
||||
// The tool card shows it as our ask_user.
|
||||
let (name, shown) = map_tool("AskUserQuestion", &input);
|
||||
assert_eq!(name, "ask_user");
|
||||
assert_eq!(shown["questions"][1]["multi_select"], true);
|
||||
// Answers: text stays, a multiple choice is joined.
|
||||
let answers = json!({"Which database?": "SQLite", "Which extras?": ["Auth", "Docs"]});
|
||||
let out = cc_answers(answers.as_object().unwrap());
|
||||
assert_eq!(out["Which database?"], "SQLite");
|
||||
assert_eq!(out["Which extras?"], "Auth, Docs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_images_are_kept() {
|
||||
let content = json!([{"type": "text", "text": "ok"}, {"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "QUJD"}}]);
|
||||
let imgs = result_images(&content);
|
||||
assert_eq!(imgs.len(), 1);
|
||||
assert_eq!((imgs[0].media_type.as_str(), imgs[0].data.as_str()), ("image/jpeg", "QUJD"));
|
||||
assert!(result_images(&json!("plain text")).is_empty());
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
+4
-2
@@ -174,7 +174,8 @@ fn parse_status(raw: &str) -> Value {
|
||||
pub async fn handle(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<GitBody>) -> ApiResult {
|
||||
let spec = crate::sandbox::spec_for(&app, Some(&user.id));
|
||||
let cfg = crate::gitcreds::global_config(&app, &user);
|
||||
let env = crate::gitcreds::identity_env(&app, &user.id);
|
||||
let mut env = crate::gitcreds::identity_env(&app, &user.id);
|
||||
env.extend(crate::sshkeys::env(&app, &user));
|
||||
SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, handle_inner(user, app, b)))).await
|
||||
}
|
||||
|
||||
@@ -401,7 +402,8 @@ fn wt_str<'a>(wt: &'a Value, key: &str) -> &'a str {
|
||||
pub async fn worktree(app: &std::sync::Arc<App>, user: &User, session: &mut crate::types::Session, action: &str) -> Result<Value, ApiError> {
|
||||
let spec = crate::sandbox::spec_for(app, Some(&user.id));
|
||||
let cfg = crate::gitcreds::global_config(app, user);
|
||||
let env = crate::gitcreds::identity_env(app, &user.id);
|
||||
let mut env = crate::gitcreds::identity_env(app, &user.id);
|
||||
env.extend(crate::sshkeys::env(app, user));
|
||||
let s = session.clone();
|
||||
let res = SANDBOX.scope(spec, GITCONFIG.scope(cfg, GITENV.scope(env, worktree_inner(s, action)))).await?;
|
||||
if let Some(cwd) = res.get("cwd").and_then(|v| v.as_str()) {
|
||||
|
||||
@@ -53,6 +53,35 @@ pub fn identity_env(app: &App, user_id: &str) -> Vec<(String, String)> {
|
||||
env
|
||||
}
|
||||
|
||||
/// What an agent needs to know about the stored git access (no secrets): git already has the
|
||||
/// credentials, so it must not look for them. None when nothing is stored.
|
||||
pub fn prompt_section(app: &App, user_id: &str) -> Option<String> {
|
||||
render_prompt(&app.store.user_setting(user_id, "git").unwrap_or_default())
|
||||
}
|
||||
|
||||
fn render_prompt(s: &GitSettings) -> Option<String> {
|
||||
let creds: Vec<&GitCredential> = s.credentials.iter().filter(|c| !c.host.trim().is_empty() && !c.token.is_empty()).collect();
|
||||
if creds.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut t = String::from("\n# Git access\nThe user has stored git credentials for these hosts (HTTPS):\n");
|
||||
for c in &creds {
|
||||
t.push_str(&format!("- {} (user {})\n", c.host.trim(), c.username.trim()));
|
||||
}
|
||||
t.push_str(
|
||||
"git uses them automatically through its credential helper: `git clone`, `fetch`, `pull` and `push` with an https:// URL of these hosts just work, no login or token needed. \
|
||||
Do NOT ask the user for a token and do NOT search the environment, config files, `gh`, SSH keys or the keychain for credentials – they are not there, and the command line shows none because git keeps them in its credential store. \
|
||||
Use https:// remote URLs (SSH keys are not set up). \
|
||||
For a REST API of such a host (pull requests, issues, releases) take the token straight from git without printing it, e.g. \
|
||||
`curl -s -H \"Authorization: token $(printf 'protocol=https\\nhost=HOST\\n\\n' | git credential fill | sed -n 's/^password=//p')\" https://HOST/api/...` \
|
||||
(GitHub: `Authorization: Bearer`). Never echo the token or write it into files, commits or your answer.\n",
|
||||
);
|
||||
if !s.name.trim().is_empty() || !s.email.trim().is_empty() {
|
||||
t.push_str(&format!("Commits are made as {} <{}> automatically.\n", s.name.trim(), s.email.trim()));
|
||||
}
|
||||
Some(t)
|
||||
}
|
||||
|
||||
pub fn home_of(app: &App, user: &User) -> PathBuf {
|
||||
app.data_dir().join("users").join(&user.username).join("home")
|
||||
}
|
||||
@@ -159,6 +188,19 @@ pub fn merge(old: &GitSettings, mut new: GitSettings) -> GitSettings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prompt_names_hosts_but_never_tokens() {
|
||||
assert!(render_prompt(&GitSettings::default()).is_none());
|
||||
let settings = GitSettings { name: "Ann".into(), email: "ann@x.org".into(), credentials: vec![GitCredential { host: "git.x.org".into(), username: "ann".into(), token: "s3cret-token".into(), protocol: String::new() }] };
|
||||
let p = render_prompt(&settings).unwrap();
|
||||
println!("{p}");
|
||||
assert!(p.contains("git.x.org (user ann)") && p.contains("git credential fill") && p.contains("Ann <ann@x.org>"));
|
||||
assert!(!p.contains("s3cret-token"));
|
||||
// Entries without a token are not offered.
|
||||
let none = GitSettings { credentials: vec![GitCredential { host: "h".into(), username: "u".into(), ..Default::default() }], ..Default::default() };
|
||||
assert!(render_prompt(&none).is_none());
|
||||
}
|
||||
|
||||
#[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() };
|
||||
|
||||
@@ -24,6 +24,7 @@ mod lsp;
|
||||
mod mentions;
|
||||
mod metrics;
|
||||
mod mcp;
|
||||
mod mcp_bridge;
|
||||
mod media;
|
||||
mod mesh3d;
|
||||
mod permission;
|
||||
@@ -34,6 +35,7 @@ mod reverse;
|
||||
mod runner;
|
||||
mod sandbox;
|
||||
mod shells;
|
||||
mod sshkeys;
|
||||
mod stats_export;
|
||||
mod store;
|
||||
mod telegram;
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
//! CodeCraft's tools for the Claude Code CLI, as an MCP server (streamable HTTP, JSON answers).
|
||||
//!
|
||||
//! The CLI only knows its own tools. For every run the server registers a secret token and
|
||||
//! passes `--mcp-config` with `http://127.0.0.1:<port>/api/mcp-bridge/<token>`; the CLI then
|
||||
//! lists and calls the tools like any MCP tool (`mcp__codecraft__<name>`). The token is the
|
||||
//! credential (the route is public) and binds the calls to the chat that started the run.
|
||||
//! Permissions are asked by the CLI through the usual `can_use_tool` request, so the approval
|
||||
//! cards, rules and project approvals work as for the CLI's own tools.
|
||||
|
||||
use crate::app::App;
|
||||
use crate::tools::{Tool, ToolCtx, ToolOutput};
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, LazyLock, Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub const SERVER: &str = "codecraft";
|
||||
/// Prefix the CLI gives our tools.
|
||||
pub const PREFIX: &str = "mcp__codecraft__";
|
||||
|
||||
/// Our tools the CLI brings itself (same job): not offered twice.
|
||||
const CLI_HAS: &[&str] = &["read", "write", "edit", "list", "glob", "grep", "bash", "bash_output", "kill_shell", "web_fetch", "web_search", "todo_write", "task", "change_directory"];
|
||||
|
||||
static TOKENS: LazyLock<Mutex<HashMap<String, String>>> = LazyLock::new(Default::default);
|
||||
|
||||
/// A registered run; the token stops working when this is dropped.
|
||||
pub struct Registration {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
impl Drop for Registration {
|
||||
fn drop(&mut self) {
|
||||
TOKENS.lock().unwrap().remove(&self.token);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register(session_id: &str) -> Registration {
|
||||
let token = format!("{}{}", crate::types::new_id(), crate::types::new_id());
|
||||
TOKENS.lock().unwrap().insert(token.clone(), session_id.to_string());
|
||||
Registration { token }
|
||||
}
|
||||
|
||||
/// `--mcp-config` value for a run.
|
||||
pub fn config_json(app: &App, token: &str) -> String {
|
||||
let port = app.config().server.bind.rsplit(':').next().and_then(|p| p.parse::<u16>().ok()).unwrap_or(8787);
|
||||
json!({"mcpServers": {SERVER: {"type": "http", "url": format!("http://127.0.0.1:{port}/api/mcp-bridge/{token}")}}}).to_string()
|
||||
}
|
||||
|
||||
/// The tools the CLI gets for a chat: what the chat's own agent would have (mode, images,
|
||||
/// the server's MCP tools …) without the ones the CLI has itself.
|
||||
pub fn tools_for(app: &Arc<App>, session: &crate::types::Session) -> Vec<Arc<dyn Tool>> {
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let root = app.root_for(session.owner_id.as_deref(), &cwd);
|
||||
let agent = crate::prompts::find_agent_for(app, &root, &cwd, &session.agent, session.owner_id.as_deref());
|
||||
let owner = session.owner_id.as_deref().and_then(|id| app.store.user(id).ok().flatten());
|
||||
crate::runner::available_tools(app, &agent, owner.as_ref()).into_iter().filter(|t| !CLI_HAS.contains(&t.name())).collect()
|
||||
}
|
||||
|
||||
/// Short note for the system prompt: what the extra tools are called.
|
||||
pub fn prompt_section(tools: &[Arc<dyn Tool>]) -> Option<String> {
|
||||
if tools.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let names: Vec<String> = tools.iter().map(|t| format!("{PREFIX}{}", t.name())).collect();
|
||||
Some(format!(
|
||||
"\n# CodeCraft tools\nBesides your own tools you have the tools of the CodeCraft server (MCP server `{SERVER}`): {}. \
|
||||
Use them for what they do – e.g. image generation (generate_image), 3D models, OpenSCAD, scheduling automations, diagnostics, the browser. \
|
||||
If one is not in your tool list, load it with ToolSearch (query `select:<name>`).\n",
|
||||
names.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
// --- protocol ----------------------------------------------------------------------------
|
||||
|
||||
fn tools_list(tools: &[Arc<dyn Tool>]) -> Value {
|
||||
json!({"tools": tools.iter().map(|t| json!({"name": t.name(), "description": t.description(), "inputSchema": t.schema()})).collect::<Vec<_>>()})
|
||||
}
|
||||
|
||||
fn call_result(res: anyhow::Result<ToolOutput>) -> Value {
|
||||
match res {
|
||||
Ok(out) => {
|
||||
let mut content = vec![json!({"type": "text", "text": out.content})];
|
||||
for img in out.images.iter().filter(|i| !i.data.is_empty()) {
|
||||
content.push(json!({"type": "image", "data": img.data, "mimeType": img.media_type}));
|
||||
}
|
||||
json!({"content": content, "isError": out.is_error})
|
||||
}
|
||||
Err(e) => json!({"content": [{"type": "text", "text": format!("Error: {e:#}")}], "isError": true}),
|
||||
}
|
||||
}
|
||||
|
||||
fn answer(id: &Value, result: Value) -> Response {
|
||||
Json(json!({"jsonrpc": "2.0", "id": id, "result": result})).into_response()
|
||||
}
|
||||
|
||||
fn failure(id: &Value, code: i64, message: &str) -> Response {
|
||||
Json(json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})).into_response()
|
||||
}
|
||||
|
||||
pub async fn handle(State(app): State<Arc<App>>, Path(token): Path<String>, Json(req): Json<Value>) -> Response {
|
||||
let Some(session_id) = TOKENS.lock().unwrap().get(&token).cloned() else {
|
||||
return (StatusCode::NOT_FOUND, "unknown or finished run").into_response();
|
||||
};
|
||||
let id = req["id"].clone();
|
||||
let method = req["method"].as_str().unwrap_or("");
|
||||
// Notifications have no answer.
|
||||
if req.get("id").is_none() || method.starts_with("notifications/") {
|
||||
return StatusCode::ACCEPTED.into_response();
|
||||
}
|
||||
match method {
|
||||
"initialize" => answer(
|
||||
&id,
|
||||
json!({
|
||||
"protocolVersion": req["params"]["protocolVersion"].as_str().unwrap_or("2025-03-26"),
|
||||
"capabilities": {"tools": {"listChanged": false}},
|
||||
"serverInfo": {"name": "codecraft", "version": env!("CARGO_PKG_VERSION")},
|
||||
}),
|
||||
),
|
||||
"ping" => answer(&id, json!({})),
|
||||
"tools/list" | "tools/call" => {
|
||||
let Ok(session) = app.session(&session_id) else { return failure(&id, -32000, "the chat is gone") };
|
||||
let tools = tools_for(&app, &session);
|
||||
if method == "tools/list" {
|
||||
return answer(&id, tools_list(&tools));
|
||||
}
|
||||
let name = req["params"]["name"].as_str().unwrap_or("");
|
||||
let Some(tool) = tools.iter().find(|t| t.name() == name).cloned() else {
|
||||
return failure(&id, -32602, &format!("unknown tool '{name}'"));
|
||||
};
|
||||
let cwd = PathBuf::from(&session.cwd);
|
||||
let agent = crate::prompts::find_agent_for(&app, &app.root_for(session.owner_id.as_deref(), &cwd), &cwd, &session.agent, session.owner_id.as_deref());
|
||||
let ctx = ToolCtx {
|
||||
app: app.clone(),
|
||||
session_id: session_id.clone(),
|
||||
call_id: crate::types::new_id(),
|
||||
cwd,
|
||||
cancel: CancellationToken::new(),
|
||||
agent: agent.name.clone(),
|
||||
roots: app.user_roots(session.owner_id.as_deref()),
|
||||
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
|
||||
clean_room: session.clean_room(app.config().craft.clean_room) && agent.name == "craft",
|
||||
owner_id: None,
|
||||
};
|
||||
let input = req["params"]["arguments"].clone();
|
||||
let input = if input.is_null() { json!({}) } else { input };
|
||||
answer(&id, call_result(tool.run(input, &ctx).await))
|
||||
}
|
||||
_ => failure(&id, -32601, "method not found"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::ImageData;
|
||||
|
||||
#[test]
|
||||
fn registration_lives_as_long_as_the_run() {
|
||||
let r = register("s1");
|
||||
assert_eq!(TOKENS.lock().unwrap().get(&r.token).map(String::as_str), Some("s1"));
|
||||
let t = r.token.clone();
|
||||
drop(r);
|
||||
assert!(!TOKENS.lock().unwrap().contains_key(&t));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_results_carry_text_and_images() {
|
||||
let out = ToolOutput {
|
||||
content: "saved".into(),
|
||||
images: vec![ImageData { media_type: "image/png".into(), data: "QUJD".into(), blob: None, size: None }, ImageData { media_type: "image/png".into(), data: String::new(), blob: Some("x".into()), size: None }],
|
||||
..Default::default()
|
||||
};
|
||||
let v = call_result(Ok(out));
|
||||
assert_eq!(v["isError"], false);
|
||||
let c = v["content"].as_array().unwrap();
|
||||
assert_eq!(c.len(), 2); // the image without data is left out
|
||||
assert_eq!((c[1]["type"].as_str(), c[1]["mimeType"].as_str()), (Some("image"), Some("image/png")));
|
||||
assert_eq!(call_result(Err(anyhow::anyhow!("boom")))["isError"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_builtins_are_not_offered_twice() {
|
||||
let all = crate::tools::builtin_tools();
|
||||
let ours: Vec<&str> = all.iter().map(|t| t.name()).filter(|n| !CLI_HAS.contains(n)).collect();
|
||||
for n in ["generate_image", "generate_3d", "openscad", "browser", "schedule", "remember", "diagnostics", "ask_user", "skill"] {
|
||||
assert!(ours.contains(&n), "{n} missing");
|
||||
}
|
||||
assert!(!ours.contains(&"read") && !ours.contains(&"bash"));
|
||||
let list = tools_list(&all.into_iter().filter(|t| t.name() == "remember").collect::<Vec<_>>());
|
||||
assert!(list["tools"][0]["inputSchema"].is_object());
|
||||
}
|
||||
|
||||
/// The whole protocol against a real App: initialize, list, call, unknown token.
|
||||
#[tokio::test]
|
||||
async fn protocol_roundtrip_with_a_real_app() {
|
||||
let dir = std::env::temp_dir().join(format!("cc-bridge-{}", std::process::id()));
|
||||
std::fs::create_dir_all(dir.join("ws")).unwrap();
|
||||
let toml = crate::config::EXAMPLE_CONFIG.lines().map(|l| {
|
||||
if l.starts_with("workspace = ") { format!("workspace = \"{}\"", dir.join("ws").display()) }
|
||||
else if l.starts_with("data_dir = ") { format!("data_dir = \"{}\"", dir.join("data").display()) }
|
||||
else { l.to_string() }
|
||||
}).collect::<Vec<_>>().join("\n");
|
||||
let cfg = crate::config::Config::parse(&toml).unwrap();
|
||||
let app = App::new(dir.join("config.toml"), cfg).await.unwrap();
|
||||
let now = crate::types::now_ms();
|
||||
let session = crate::types::Session {
|
||||
id: crate::types::new_id(), title: String::new(), cwd: dir.join("ws").display().to_string(), model: "x/y".into(), agent: "build".into(), permission_mode: "bypass".into(),
|
||||
parent_id: None, owner_id: None, created_at: now, updated_at: now, todos: Value::Null, last_usage: None, clean_room: None, phase: None, worktree: None,
|
||||
pinned: false, archived: false, folder: None, instructions: String::new(), telegram_quiet: true, summary: String::new(), color: None, icon: None,
|
||||
};
|
||||
let session = app.save_session(session).unwrap();
|
||||
let reg = register(&session.id);
|
||||
let rpc = |req: Value| {
|
||||
let app = app.clone();
|
||||
let token = reg.token.clone();
|
||||
async move {
|
||||
let resp = handle(State(app), Path(token), Json(req)).await;
|
||||
let status = resp.status();
|
||||
let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap();
|
||||
(status, serde_json::from_slice::<Value>(&bytes).unwrap_or(Value::Null))
|
||||
}
|
||||
};
|
||||
let (st, init) = rpc(json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18"}})).await;
|
||||
assert_eq!(st, StatusCode::OK);
|
||||
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
|
||||
assert!(init["result"]["capabilities"]["tools"].is_object());
|
||||
let (st, _) = rpc(json!({"jsonrpc": "2.0", "method": "notifications/initialized"})).await;
|
||||
assert_eq!(st, StatusCode::ACCEPTED);
|
||||
let (_, list) = rpc(json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"})).await;
|
||||
let names: Vec<&str> = list["result"]["tools"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect();
|
||||
assert!(names.contains(&"remember") && names.contains(&"openscad") && names.contains(&"browser"), "{names:?}");
|
||||
assert!(!names.contains(&"read") && !names.contains(&"bash"), "{names:?}");
|
||||
// A real tool call: `remember` writes the project memory.
|
||||
let (_, call) = rpc(json!({"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "remember", "arguments": {"note": "bridge works"}}})).await;
|
||||
assert_eq!(call["result"]["isError"], false, "{call}");
|
||||
let mem = std::fs::read_to_string(dir.join("ws/.codecraft/memory.md")).unwrap_or_default();
|
||||
assert!(mem.contains("bridge works"), "{mem}");
|
||||
let (_, bad) = rpc(json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "bash", "arguments": {"command": "id"}}})).await;
|
||||
assert!(bad["error"].is_object());
|
||||
// A finished run's token is dead.
|
||||
let token = reg.token.clone();
|
||||
drop(reg);
|
||||
let resp = handle(State(app.clone()), Path(token), Json(json!({"jsonrpc": "2.0", "id": 5, "method": "ping"}))).await;
|
||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -298,6 +298,12 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &
|
||||
s.push_str(&format!("\n# About the user (personal memory)\n{}\n", memory.trim()));
|
||||
}
|
||||
}
|
||||
if let Some(git) = owner.and_then(|u| crate::gitcreds::prompt_section(app, &u.id)) {
|
||||
s.push_str(&git);
|
||||
}
|
||||
if let Some(ssh) = owner.and_then(|u| crate::sshkeys::prompt_section(app, &u.id)) {
|
||||
s.push_str(&ssh);
|
||||
}
|
||||
match std::fs::read_to_string(cwd.join(".codecraft").join("memory.md")) {
|
||||
Ok(mem) => s.push_str(&format!("\n# Project memory\nFacts learned earlier in this project (keep it current with the remember tool):\n{}\n", mem.chars().take(20_000).collect::<String>())),
|
||||
Err(_) if !agent.read_only => s.push_str("\n# Project memory\nWhen you learn something lasting about this project (how to build/test, conventions, pitfalls), save it with the remember tool.\n"),
|
||||
|
||||
@@ -1723,10 +1723,43 @@ async fn compact_inner(app: Arc<App>, session_id: &str, cancel: CancellationToke
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Text of the marker `/clear` puts into the history (the chat view recognises it).
|
||||
pub const CLEARED_MARKER: &str = "[Context cleared by the user. Earlier messages are no longer part of this conversation – start fresh and do not refer to them.]";
|
||||
|
||||
/// `/clear`: the model forgets the conversation so far (the chat keeps showing it). Like a
|
||||
/// compaction without a summary: history before the marker is not sent any more.
|
||||
pub fn clear_context(app: &App, session_id: &str) -> Result<()> {
|
||||
save_user_parts(app, session_id, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }])?;
|
||||
app.update_session(session_id, |s| {
|
||||
s.last_usage = None;
|
||||
s.todos = Value::Null;
|
||||
})?;
|
||||
app.with_runtime(session_id, |r| r.read_files.clear());
|
||||
// Claude Code continues its own conversation: start a new one.
|
||||
if let Some(owner) = app.session(session_id).ok().and_then(|s| s.owner_id) {
|
||||
let _ = app.store.set_user_setting(&owner, &format!("claude_sid:{session_id}"), &Option::<String>::None);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cleared_marker_starts_a_new_history() {
|
||||
let mk = |role, parts| Message::new(role, parts);
|
||||
let all = vec![
|
||||
mk(Role::User, vec![Part::Text { text: "old question".into() }]),
|
||||
mk(Role::Assistant, vec![Part::Text { text: "old answer".into() }]),
|
||||
mk(Role::User, vec![Part::Summary { text: CLEARED_MARKER.into(), auto: false, tokens: None }]),
|
||||
mk(Role::User, vec![Part::Text { text: "new question".into() }]),
|
||||
];
|
||||
let active = active_history(all);
|
||||
assert_eq!(active.len(), 2);
|
||||
assert!(!active.iter().any(|m| message_text(m).contains("old")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_keeps_head_and_tail_within_budget() {
|
||||
let text = format!("{}{}{}", "A".repeat(5000), "M".repeat(5000), "Z".repeat(5000));
|
||||
|
||||
@@ -132,7 +132,10 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
|
||||
if new_session {
|
||||
push(&[&"--new-session"]);
|
||||
}
|
||||
let path = format!("{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin");
|
||||
// SSH wrappers (user's keys); only when keys are set up.
|
||||
let ssh_wrapper = spec.home.join(crate::sshkeys::SANDBOX_BIN).join("ssh");
|
||||
let ssh_bin = format!("{home}/{}", crate::sshkeys::SANDBOX_BIN);
|
||||
let path = format!("{}{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin", if ssh_wrapper.exists() { format!("{ssh_bin}:") } else { String::new() });
|
||||
let npm = format!("{home}/.npm-global");
|
||||
let pipx = format!("{home}/.local/pipx");
|
||||
let pipx_bin = format!("{home}/.local/bin");
|
||||
@@ -152,6 +155,10 @@ pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_sessio
|
||||
] {
|
||||
push(&[&"--setenv", &k, &v]);
|
||||
}
|
||||
if ssh_wrapper.exists() {
|
||||
let cmd = format!("{ssh_bin}/ssh");
|
||||
push(&[&"--setenv", &"GIT_SSH_COMMAND", &cmd]);
|
||||
}
|
||||
for (k, v) in &spec.env {
|
||||
push(&[&"--setenv", k, v]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
//! Per-user SSH keys for agents, terminals and the git panel.
|
||||
//!
|
||||
//! The keys live in the database and are rendered into the user's home (`.ssh/`), like the git
|
||||
//! credentials. OpenSSH takes `~` from the passwd entry, not from `$HOME`, so plain `ssh` would
|
||||
//! not look into the sandbox home. A small `ssh`/`scp`/`sftp` wrapper directory goes first in PATH
|
||||
//! and applies the managed config with `-F`; `GIT_SSH_COMMAND` points at the wrapper. Two
|
||||
//! variants: one for the sandbox (paths through `${HOME}`) and one for unsandboxed runs (admins;
|
||||
//! absolute host paths).
|
||||
|
||||
use crate::app::App;
|
||||
use crate::store::User;
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SshKey {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub public: String,
|
||||
pub fingerprint: String,
|
||||
/// OpenSSH private key (never sent to the UI).
|
||||
#[serde(default)]
|
||||
pub private: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SshSettings {
|
||||
#[serde(default)]
|
||||
pub keys: Vec<SshKey>,
|
||||
}
|
||||
|
||||
/// Keys without the private part, for the UI.
|
||||
pub fn masked(s: &SshSettings) -> Vec<SshKey> {
|
||||
s.keys.iter().map(|k| SshKey { private: String::new(), ..k.clone() }).collect()
|
||||
}
|
||||
|
||||
fn ssh_dir(home: &Path) -> PathBuf {
|
||||
home.join(".ssh")
|
||||
}
|
||||
|
||||
/// Wrappers for unsandboxed runs.
|
||||
fn bin_dir(home: &Path) -> PathBuf {
|
||||
home.join(".codecraft-bin")
|
||||
}
|
||||
|
||||
/// Wrappers inside the sandbox (see `sandbox::bwrap_args`).
|
||||
pub const SANDBOX_BIN: &str = ".codecraft-sbin";
|
||||
|
||||
fn key_file(id: &str) -> String {
|
||||
format!("codecraft_{id}")
|
||||
}
|
||||
|
||||
/// SSH client config. `sandbox`: paths through `${HOME}` (the home is mounted at a different
|
||||
/// place there); otherwise absolute paths below `home` (wrapper for unsandboxed runs).
|
||||
pub fn render_config(keys: &[SshKey], home: &Path, sandbox: bool) -> String {
|
||||
let mut c = String::from("# Managed by CodeCraft (Settings → Account → SSH keys). Do not edit.\nHost *\n");
|
||||
for k in keys {
|
||||
if sandbox {
|
||||
c.push_str(&format!("\tIdentityFile ${{HOME}}/.ssh/{}\n", key_file(&k.id)));
|
||||
} else {
|
||||
c.push_str(&format!("\tIdentityFile {}\n", ssh_dir(home).join(key_file(&k.id)).display()));
|
||||
}
|
||||
}
|
||||
// Trust a host on first contact and refuse changed keys afterwards.
|
||||
c.push_str("\tStrictHostKeyChecking accept-new\n\tServerAliveInterval 30\n");
|
||||
if sandbox {
|
||||
c.push_str("\tUserKnownHostsFile ${HOME}/.ssh/known_hosts\n");
|
||||
} else {
|
||||
c.push_str(&format!("\tUserKnownHostsFile {}\n", ssh_dir(home).join("known_hosts").display()));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn chmod(p: &Path, mode: u32) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode))?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
fn chmod(_: &Path, _: u32) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write keys, config and wrappers into the user's home (removes files of deleted keys).
|
||||
pub fn write_files(app: &App, user: &User, s: &SshSettings) -> Result<()> {
|
||||
let home = crate::gitcreds::home_of(app, user);
|
||||
let dir = ssh_dir(&home);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
chmod(&dir, 0o700)?;
|
||||
// Old key files.
|
||||
for e in std::fs::read_dir(&dir)?.flatten() {
|
||||
let n = e.file_name().to_string_lossy().into_owned();
|
||||
if n.starts_with("codecraft_") && !s.keys.iter().any(|k| n == key_file(&k.id) || n == format!("{}.pub", key_file(&k.id))) {
|
||||
let _ = std::fs::remove_file(e.path());
|
||||
}
|
||||
}
|
||||
if s.keys.is_empty() {
|
||||
// No keys: no wrappers either, ssh behaves as usual.
|
||||
let _ = std::fs::remove_dir_all(bin_dir(&home));
|
||||
let _ = std::fs::remove_dir_all(home.join(SANDBOX_BIN));
|
||||
for f in ["config-sandbox", "config-host"] {
|
||||
let _ = std::fs::remove_file(dir.join(f));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
for k in &s.keys {
|
||||
let p = dir.join(key_file(&k.id));
|
||||
// The file is replaced, not appended: remove it first so the mode applies.
|
||||
let _ = std::fs::remove_file(&p);
|
||||
std::fs::write(&p, &k.private)?;
|
||||
chmod(&p, 0o600)?;
|
||||
let pubp = dir.join(format!("{}.pub", key_file(&k.id)));
|
||||
std::fs::write(&pubp, format!("{}\n", k.public.trim()))?;
|
||||
chmod(&pubp, 0o644)?;
|
||||
}
|
||||
// The user's own ~/.ssh/config (if any) is included after the managed part.
|
||||
let own = dir.join("config");
|
||||
let sandbox_cfg = dir.join("config-sandbox");
|
||||
std::fs::write(&sandbox_cfg, format!("{}\nInclude /home/{}/.ssh/config\n", render_config(&s.keys, &home, true), user.username))?;
|
||||
chmod(&sandbox_cfg, 0o600)?;
|
||||
let host_cfg = dir.join("config-host");
|
||||
std::fs::write(&host_cfg, format!("{}\nInclude {}\n", render_config(&s.keys, &home, false), own.display()))?;
|
||||
chmod(&host_cfg, 0o600)?;
|
||||
for (bin, config) in [(bin_dir(&home), format!("'{}'", host_cfg.display())), (home.join(SANDBOX_BIN), "\"$HOME/.ssh/config-sandbox\"".to_string())] {
|
||||
std::fs::create_dir_all(&bin)?;
|
||||
for name in ["ssh", "scp", "sftp"] {
|
||||
// The image's /usr/local/bin wrappers cope with a uid without passwd entry.
|
||||
let local = format!("/usr/local/bin/{name}");
|
||||
let real = if Path::new(&local).exists() { local } else { format!("/usr/bin/{name}") };
|
||||
let p = bin.join(name);
|
||||
std::fs::write(&p, format!("#!/bin/sh\nexec {real} -F {config} \"$@\"\n"))?;
|
||||
chmod(&p, 0o755)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Environment for processes that run without the sandbox: the wrappers first in PATH, git over
|
||||
/// SSH through the wrapper. Empty when the user has no keys.
|
||||
pub fn env(app: &App, user: &User) -> Vec<(String, String)> {
|
||||
let s: SshSettings = app.store.user_setting(&user.id, "ssh").unwrap_or_default();
|
||||
if s.keys.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let bin = bin_dir(&crate::gitcreds::home_of(app, user));
|
||||
if !bin.join("ssh").exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
let path = format!("{}:{}", bin.display(), std::env::var("PATH").unwrap_or_else(|_| "/usr/local/bin:/usr/bin:/bin".into()));
|
||||
vec![("PATH".into(), path), ("GIT_SSH_COMMAND".into(), bin.join("ssh").display().to_string())]
|
||||
}
|
||||
|
||||
/// What an agent needs to know (public keys only). None without keys.
|
||||
pub fn prompt_section(app: &App, user_id: &str) -> Option<String> {
|
||||
render_prompt(&app.store.user_setting(user_id, "ssh").unwrap_or_default())
|
||||
}
|
||||
|
||||
fn render_prompt(s: &SshSettings) -> Option<String> {
|
||||
if s.keys.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let names: Vec<&str> = s.keys.iter().map(|k| k.name.as_str()).collect();
|
||||
Some(format!(
|
||||
"\n# SSH access\nThe user has SSH keys set up ({}). `ssh`, `scp`, `sftp`, `rsync -e ssh` and `git` over ssh:// or git@host use them automatically – \
|
||||
no password, no `ssh-add`, no agent. A host is trusted on first contact. Do NOT generate new keys or search for keys or passwords; \
|
||||
if a server refuses the connection, tell the user that their public key must be added to the server's authorized_keys. \
|
||||
Commands run without a terminal, so password or passphrase prompts fail.\n",
|
||||
names.join(", ")
|
||||
))
|
||||
}
|
||||
|
||||
// --- creating keys ---------------------------------------------------------------------
|
||||
|
||||
async fn keygen(args: &[&str], stdin: Option<&str>) -> Result<String> {
|
||||
let mut cmd = Command::new("ssh-keygen");
|
||||
cmd.args(args).env_clear().env("PATH", std::env::var("PATH").unwrap_or_default()).stdin(std::process::Stdio::piped()).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
|
||||
let mut child = cmd.spawn().context("ssh-keygen is not installed on the server")?;
|
||||
if let Some(mut si) = child.stdin.take() {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
if let Some(t) = stdin {
|
||||
let _ = si.write_all(t.as_bytes()).await;
|
||||
}
|
||||
}
|
||||
let out = tokio::time::timeout(std::time::Duration::from_secs(20), child.wait_with_output()).await.map_err(|_| anyhow!("ssh-keygen timed out"))??;
|
||||
if !out.status.success() {
|
||||
bail!("{}", String::from_utf8_lossy(&out.stderr).trim());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn temp_dir() -> Result<PathBuf> {
|
||||
let d = std::env::temp_dir().join(format!("cc-ssh-{}", crate::types::new_id()));
|
||||
std::fs::create_dir_all(&d)?;
|
||||
chmod(&d, 0o700)?;
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
/// A new ed25519 key without passphrase.
|
||||
pub async fn generate(name: &str) -> Result<SshKey> {
|
||||
let d = temp_dir()?;
|
||||
let f = d.join("key");
|
||||
let comment = format!("codecraft-{}", name.replace(char::is_whitespace, "-"));
|
||||
let res = async {
|
||||
keygen(&["-q", "-t", "ed25519", "-N", "", "-C", &comment, "-f", &f.to_string_lossy()], None).await?;
|
||||
let private = std::fs::read_to_string(&f)?;
|
||||
let public = std::fs::read_to_string(d.join("key.pub"))?;
|
||||
finish(name, private, public.trim().to_string()).await
|
||||
}
|
||||
.await;
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
res
|
||||
}
|
||||
|
||||
/// An existing private key (OpenSSH or PEM, without passphrase).
|
||||
pub async fn import(name: &str, private: &str) -> Result<SshKey> {
|
||||
let mut text = private.trim().replace("\r\n", "\n");
|
||||
if !text.starts_with("-----BEGIN ") || !text.contains("PRIVATE KEY-----") {
|
||||
bail!("that does not look like a private key (-----BEGIN … PRIVATE KEY-----)");
|
||||
}
|
||||
text.push('\n');
|
||||
let d = temp_dir()?;
|
||||
let f = d.join("key");
|
||||
let res = async {
|
||||
std::fs::write(&f, &text)?;
|
||||
chmod(&f, 0o600)?;
|
||||
let public = keygen(&["-y", "-P", "", "-f", &f.to_string_lossy()], None).await.map_err(|e| anyhow!("cannot read the key (keys with a passphrase are not supported): {e}"))?;
|
||||
finish(name, text.clone(), public.trim().to_string()).await
|
||||
}
|
||||
.await;
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
res
|
||||
}
|
||||
|
||||
async fn finish(name: &str, private: String, public: String) -> Result<SshKey> {
|
||||
let fp = keygen(&["-l", "-f", "-"], Some(&format!("{public}\n"))).await.unwrap_or_default();
|
||||
let fingerprint = fp.split_whitespace().nth(1).unwrap_or("").to_string();
|
||||
Ok(SshKey { id: crate::types::new_id().chars().take(10).collect(), name: name.to_string(), public, fingerprint, private })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(id: &str) -> SshKey {
|
||||
SshKey { id: id.into(), name: "laptop".into(), public: "ssh-ed25519 AAAA x".into(), fingerprint: "SHA256:x".into(), private: "SECRET".into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_has_both_path_styles() {
|
||||
let home = Path::new("/data/users/ann/home");
|
||||
let t = render_config(&[key("k1")], home, true);
|
||||
assert!(t.contains("IdentityFile ${HOME}/.ssh/codecraft_k1") && t.contains("UserKnownHostsFile ${HOME}/.ssh/known_hosts"));
|
||||
assert!(t.contains("StrictHostKeyChecking accept-new"));
|
||||
let a = render_config(&[key("k1")], home, false);
|
||||
assert!(a.contains("IdentityFile /data/users/ann/home/.ssh/codecraft_k1"));
|
||||
assert!(a.contains("UserKnownHostsFile /data/users/ann/home/.ssh/known_hosts"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_config_resolves_through_home() {
|
||||
if std::process::Command::new("ssh").arg("-V").output().is_err() {
|
||||
return;
|
||||
}
|
||||
let d = std::env::temp_dir().join(format!("cc-sshcfg-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
let cfg = d.join("cfg");
|
||||
std::fs::write(&cfg, render_config(&[key("k1")], Path::new("/unused"), true)).unwrap();
|
||||
let out = std::process::Command::new("ssh").arg("-F").arg(&cfg).args(["-G", "example.org"]).env("HOME", "/sandbox/home").output().unwrap();
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_lowercase();
|
||||
// `-G` shows identity files unexpanded (expanded when used, ssh_config(5)).
|
||||
assert!(text.contains("identityfile ${home}/.ssh/codecraft_k1"), "{text}");
|
||||
assert!(text.contains("userknownhostsfile /sandbox/home/.ssh/known_hosts"), "{text}");
|
||||
std::fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_and_ui_view_never_contain_the_private_key() {
|
||||
let s = SshSettings { keys: vec![key("k1")] };
|
||||
assert!(masked(&s).iter().all(|k| k.private.is_empty()));
|
||||
let p = render_prompt(&s).unwrap();
|
||||
assert!(p.contains("laptop") && !p.contains("SECRET"));
|
||||
assert!(render_prompt(&SshSettings::default()).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn generated_and_imported_keys_match() {
|
||||
if std::process::Command::new("ssh-keygen").arg("-?").output().is_err() {
|
||||
return; // not installed here
|
||||
}
|
||||
let k = generate("test key").await.unwrap();
|
||||
assert!(k.public.starts_with("ssh-ed25519 ") && k.fingerprint.starts_with("SHA256:") && k.private.contains("PRIVATE KEY"));
|
||||
let again = import("copy", &k.private).await.unwrap();
|
||||
assert_eq!(again.fingerprint, k.fingerprint);
|
||||
assert!(import("bad", "hello").await.is_err());
|
||||
}
|
||||
}
|
||||
@@ -409,6 +409,16 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// (title, cwd) of all other chats (subagent sessions of this chat excluded).
|
||||
pub fn other_session_cwds(&self, id: &str) -> Result<Vec<(String, String)>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT COALESCE(json_extract(data, '$.title'), ''), COALESCE(json_extract(data, '$.cwd'), '') FROM sessions WHERE id != ?1 AND COALESCE(parent_id, '') != ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map([id], |r| Ok((r.get(0)?, r.get(1)?)))?;
|
||||
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||
}
|
||||
|
||||
pub fn add_message(&self, session_id: &str, m: &Message) -> Result<()> {
|
||||
// Images go to the blob table (encoded before taking the lock).
|
||||
let mut m = m.clone();
|
||||
|
||||
@@ -162,7 +162,8 @@ pub fn spawn(app: &Arc<App>, user: &User, cwd: &Path, session_id: Option<String>
|
||||
.current_dir(cwd)
|
||||
.env("TERM", "xterm-256color")
|
||||
.env("CODECRAFT", "1")
|
||||
.envs(crate::gitcreds::identity_env(app, &user.id));
|
||||
.envs(crate::gitcreds::identity_env(app, &user.id))
|
||||
.envs(crate::sshkeys::env(app, user));
|
||||
match crate::gitcreds::global_config(app, user) {
|
||||
Some(cfg) => c.env("GIT_CONFIG_GLOBAL", cfg),
|
||||
None => c,
|
||||
|
||||
@@ -155,6 +155,7 @@ pub fn shell_command(ctx: &ToolCtx, command: &str) -> std::result::Result<Comman
|
||||
c.env("GIT_CONFIG_GLOBAL", cfg);
|
||||
}
|
||||
c.envs(crate::gitcreds::identity_env(&ctx.app, &u.id));
|
||||
c.envs(crate::sshkeys::env(&ctx.app, &u));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { del, get } from "./api";
|
||||
import { choiceDialog, confirmDialog, toast } from "./dialogs";
|
||||
import { t } from "./i18n";
|
||||
import { formatSize } from "./components/FileFigure";
|
||||
|
||||
interface FolderInfo {
|
||||
own: boolean;
|
||||
path?: string;
|
||||
files?: number;
|
||||
bytes?: number;
|
||||
git?: boolean;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a chat after asking. A chat with a folder of its own (not shared with other chats, not a
|
||||
* project) gets the question whether the folder goes with it. Resolves to true when it was deleted.
|
||||
*/
|
||||
export async function deleteChat(session: { id: string; parent_id?: string | null }): Promise<boolean> {
|
||||
const folder = session.parent_id ? undefined : await get<FolderInfo>(`/sessions/${session.id}/folder`).catch(() => undefined);
|
||||
let withFolder = false;
|
||||
if (folder?.own) {
|
||||
const size = `${folder.truncated ? "> " : ""}${t("chat.folderFiles", { n: folder.files ?? 0 })}, ${formatSize(folder.bytes ?? 0)}`;
|
||||
const choice = await choiceDialog({
|
||||
title: t("chat.deleteTitle"),
|
||||
message: `${t("chat.deleteWithFolder", { path: folder.path ?? "", size })}${folder.git ? `\n\n${t("chat.folderHasGit")}` : ""}`,
|
||||
choices: [
|
||||
{ value: "chat", label: t("chat.deleteOnlyChat") },
|
||||
{ value: "both", label: t("chat.deleteChatAndFolder"), danger: true },
|
||||
],
|
||||
});
|
||||
if (!choice) return false;
|
||||
withFolder = choice === "both";
|
||||
} else if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const r = await del<{ folder_deleted?: boolean; folder_error?: string }>(`/sessions/${session.id}${withFolder ? "?delete_folder=true" : ""}`);
|
||||
if (r?.folder_error) toast(r.folder_error);
|
||||
else if (r?.folder_deleted) toast(t("chat.folderDeleted"), "success");
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@ import { UsageOverlay } from "./UsageOverlay";
|
||||
import { RewindDialog } from "./RewindDialog";
|
||||
import { ShellsChip } from "./ShellsPanel";
|
||||
import { TodoList, ToolCard } from "./ToolCard";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
import { promptDialog, toast } from "../dialogs";
|
||||
import { deleteChat } from "../chatActions";
|
||||
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
|
||||
import { ColorSwatches, IconPicker } from "./ColorSwatches";
|
||||
import { ProjectPermissions } from "./ProjectPermissions";
|
||||
@@ -459,13 +460,15 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
|
||||
return (
|
||||
<details className="summary-divider" data-mid={m.id} onToggle={(e) => setSummaryOpen((e.currentTarget as HTMLDetailsElement).open)}>
|
||||
<summary>
|
||||
{summary.auto
|
||||
{summary.text.startsWith("[Context cleared")
|
||||
? t("chat.cleared")
|
||||
: summary.auto
|
||||
? t("chat.compactedAuto", { tokens: summary.tokens ? fmtCompact(summary.tokens) : "?" })
|
||||
: summary.tokens
|
||||
? t("chat.compactedManual", { tokens: fmtCompact(summary.tokens) })
|
||||
: t("chat.compacted")}
|
||||
</summary>
|
||||
{summaryOpen && <Markdown text={summary.text} />}
|
||||
{summaryOpen && !summary.text.startsWith("[Context cleared") && <Markdown text={summary.text} />}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -755,9 +758,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||
await del(`/sessions/${sessionId}`);
|
||||
navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
|
||||
if (await deleteChat(session)) navigate(session.parent_id ? `/s/${session.parent_id}` : "/new");
|
||||
};
|
||||
|
||||
// Context usage of the last request relative to the model's window.
|
||||
|
||||
@@ -20,6 +20,7 @@ interface Suggestion {
|
||||
|
||||
const BUILTIN: { name: string; key: Key }[] = [
|
||||
{ name: "compact", key: "command.compact" },
|
||||
{ name: "clear", key: "command.clear" },
|
||||
{ name: "new", key: "command.new" },
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ColorSwatches, IconPicker } from "./ColorSwatches";
|
||||
import { deleteChat } from "../chatActions";
|
||||
import { Icon } from "./Icon";
|
||||
import { StatusLeds } from "./StatusLeds";
|
||||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||
@@ -98,11 +98,21 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
||||
<Icon name="folder-up" /> {t("sidebar.removeFromFolder")}
|
||||
</button>
|
||||
)}
|
||||
<ColorSwatches value={s.color} onChange={(color) => (setMenu(false), organise(s.id, { color }))} />
|
||||
<IconPicker value={s.icon} color={s.color} onChange={(icon) => (setMenu(false), organise(s.id, { icon }))} />
|
||||
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
|
||||
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
|
||||
</button>
|
||||
{!s.shared && (
|
||||
<button
|
||||
role="menuitem"
|
||||
className="danger"
|
||||
onClick={async () => {
|
||||
setMenu(false);
|
||||
if ((await deleteChat(s)) && active) navigate("/new");
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" /> {t("chat.delete")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,7 @@ export function ToolCard({ call, result, running, baseDir }: { call: ToolCall; r
|
||||
const diffLines = typeof meta?.diff === "string" ? meta.diff.split("\n").length : 0;
|
||||
const openByDefault =
|
||||
(status === "err" && !!result) ||
|
||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || call.name === "generate_image" || call.name === "generate_3d"));
|
||||
(!!result && !result.is_error && ((diffLines > 0 && diffLines <= 40) || (result.images?.length ?? 0) > 0 || call.name === "generate_image" || call.name === "generate_3d"));
|
||||
const open = toggled ?? openByDefault;
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ import { post } from "../../api";
|
||||
import { useT } from "../../i18n";
|
||||
import { useStore } from "../../store";
|
||||
import { GitAccount } from "./GitAccount";
|
||||
import { SshKeys } from "./SshKeys";
|
||||
import { ApiTokens } from "./ApiTokens";
|
||||
import { CliInstall } from "./CliInstall";
|
||||
import { Memory } from "./Memory";
|
||||
@@ -54,6 +55,7 @@ export function Account() {
|
||||
<Memory />
|
||||
<Snippets />
|
||||
<GitAccount />
|
||||
<SshKeys />
|
||||
<CliInstall />
|
||||
<ClaudeCode />
|
||||
<Telegram />
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { del, get, post } from "../../api";
|
||||
import { useT } from "../../i18n";
|
||||
import { confirmDialog, toast } from "../../dialogs";
|
||||
import { Icon } from "../Icon";
|
||||
|
||||
interface Key {
|
||||
id: string;
|
||||
name: string;
|
||||
public: string;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
/** SSH keys of the user: used by agents, the terminal and the git panel to reach other servers. */
|
||||
export function SshKeys() {
|
||||
const t = useT();
|
||||
const [keys, setKeys] = useState<Key[]>();
|
||||
const [name, setName] = useState("");
|
||||
const [priv, setPriv] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
get<Key[]>("/account/ssh")
|
||||
.then(setKeys)
|
||||
.catch((e) => toast(e.message));
|
||||
}, []);
|
||||
if (!keys) return null;
|
||||
|
||||
const add = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setKeys(await post<Key[]>("/account/ssh", { name, private: importing ? priv : undefined }));
|
||||
setName("");
|
||||
setPriv("");
|
||||
toast(t("ssh.added"), "success");
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copy = (text: string) => navigator.clipboard?.writeText(text).then(() => toast(t("ssh.copied"), "success"), () => toast(t("ssh.copyFailed")));
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
<Icon name="lock" /> {t("ssh.title")}
|
||||
</h3>
|
||||
<div className="hint" style={{ marginBottom: 10 }}>
|
||||
{t("ssh.hint")}
|
||||
</div>
|
||||
{keys.length === 0 && <div className="hint">{t("ssh.none")}</div>}
|
||||
{keys.map((k) => (
|
||||
<div key={k.id} className="ssh-key">
|
||||
<div className="row">
|
||||
<b>{k.name}</b>
|
||||
<span className="hint mono">{k.fingerprint}</span>
|
||||
<span className="spacer" />
|
||||
<button className="small" onClick={() => copy(k.public)}>
|
||||
<Icon name="copy" /> {t("ssh.copyPublic")}
|
||||
</button>
|
||||
<button
|
||||
className="small"
|
||||
title={t("common.delete")}
|
||||
aria-label={t("common.delete")}
|
||||
onClick={async () => {
|
||||
if (await confirmDialog({ message: t("ssh.deleteConfirm", { name: k.name }), danger: true })) {
|
||||
del<Key[]>(`/account/ssh/${k.id}`).then(setKeys).catch((e) => toast(e.message));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</div>
|
||||
<input readOnly value={k.public} onFocus={(e) => e.currentTarget.select()} aria-label={t("ssh.public")} className="mono" />
|
||||
</div>
|
||||
))}
|
||||
<div className="row" style={{ marginTop: 10, flexWrap: "wrap" }}>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder={t("ssh.name")} maxLength={60} aria-label={t("ssh.name")} style={{ flex: "1 1 160px" }} />
|
||||
<button className="primary" disabled={busy || !name.trim() || (importing && !priv.trim())} onClick={add}>
|
||||
{importing ? t("ssh.import") : t("ssh.generate")}
|
||||
</button>
|
||||
<label className="row" style={{ gap: 6 }}>
|
||||
<input type="checkbox" checked={importing} onChange={(e) => setImporting(e.target.checked)} />
|
||||
{t("ssh.importExisting")}
|
||||
</label>
|
||||
</div>
|
||||
{importing && (
|
||||
<>
|
||||
<textarea rows={5} className="mono" value={priv} onChange={(e) => setPriv(e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" aria-label={t("ssh.private")} style={{ width: "100%", marginTop: 8 }} spellCheck={false} autoComplete="off" />
|
||||
<div className="hint">{t("ssh.importHint")}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+36
-8
@@ -20,8 +20,15 @@ interface PromptOpts {
|
||||
confirmLabel?: string;
|
||||
}
|
||||
|
||||
interface ChoiceOpts {
|
||||
title?: string;
|
||||
message: string;
|
||||
choices: { value: string; label: string; danger?: boolean }[];
|
||||
}
|
||||
|
||||
type Dialog =
|
||||
| ({ kind: "confirm"; id: number; resolve: (v: boolean) => void } & ConfirmOpts)
|
||||
| ({ kind: "choice"; id: number; resolve: (v: string | null) => void } & ChoiceOpts)
|
||||
| ({ kind: "prompt"; id: number; resolve: (v: string | null) => void } & PromptOpts);
|
||||
|
||||
interface Toast {
|
||||
@@ -45,6 +52,14 @@ export function confirmDialog(opts: ConfirmOpts | string): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Several actions to choose from (plus Cancel): resolves to the chosen value, or null. */
|
||||
export function choiceDialog(opts: ChoiceOpts): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
dialogs = [...dialogs, { kind: "choice", id: ++seq, resolve, ...opts }];
|
||||
emit();
|
||||
});
|
||||
}
|
||||
|
||||
export function promptDialog(opts: PromptOpts | string): Promise<string | null> {
|
||||
const o = typeof opts === "string" ? { message: opts } : opts;
|
||||
return new Promise((resolve) => {
|
||||
@@ -108,14 +123,27 @@ function DialogView({ d }: { d: Dialog }) {
|
||||
style={{ width: "100%" }}
|
||||
/>
|
||||
)}
|
||||
<div className="row dialog-actions">
|
||||
<button type="button" onClick={() => close(d, cancelValue)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
|
||||
{d.confirmLabel ?? t("common.ok")}
|
||||
</button>
|
||||
</div>
|
||||
{d.kind === "choice" ? (
|
||||
<div className="row dialog-actions dialog-choices">
|
||||
<button type="button" onClick={() => close(d, null)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
{d.choices.map((c, i) => (
|
||||
<button key={c.value} ref={i === d.choices.length - 1 ? ok : undefined} type="button" className={c.danger ? "danger-solid" : i === d.choices.length - 1 ? "primary" : undefined} onClick={() => close(d, c.value)}>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="row dialog-actions">
|
||||
<button type="button" onClick={() => close(d, cancelValue)}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button ref={ok} type="submit" className={d.kind === "confirm" && d.danger ? "danger-solid" : "primary"}>
|
||||
{d.confirmLabel ?? t("common.ok")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1144,4 +1144,28 @@ export const de: Messages = {
|
||||
"stats.table.agent": "nach Modus",
|
||||
"stats.table.user": "nach Benutzer",
|
||||
"stats.table.session": "teuerste Chats",
|
||||
"command.clear": "Bisheriges Gespräch vergessen (der Chat bleibt sichtbar)",
|
||||
"chat.cleared": "Kontext geleert – das Modell sieht die Nachrichten darüber nicht mehr",
|
||||
"ssh.title": "SSH-Schlüssel",
|
||||
"ssh.hint": "Schlüssel, mit denen Agenten, Terminal und Git-Panel andere Server erreichen (ssh, scp, rsync, Git über ssh). Sie werden automatisch genutzt; Hosts werden beim ersten Kontakt akzeptiert. Den öffentlichen Schlüssel trägst du in die authorized_keys des Servers ein.",
|
||||
"ssh.none": "Noch keine Schlüssel.",
|
||||
"ssh.name": "Name (z. B. Laptop, Build-Server)",
|
||||
"ssh.generate": "Neuen Schlüssel erzeugen",
|
||||
"ssh.import": "Schlüssel importieren",
|
||||
"ssh.importExisting": "Vorhandenen Schlüssel importieren",
|
||||
"ssh.importHint": "Nur private Schlüssel ohne Passphrase. Der Schlüssel wird für dein Konto auf dem Server gespeichert.",
|
||||
"ssh.private": "Privater Schlüssel",
|
||||
"ssh.public": "Öffentlicher Schlüssel",
|
||||
"ssh.copyPublic": "Öffentlichen Schlüssel kopieren",
|
||||
"ssh.copied": "Öffentlicher Schlüssel kopiert",
|
||||
"ssh.copyFailed": "Kopieren nicht möglich – Text von Hand markieren",
|
||||
"ssh.added": "Schlüssel hinzugefügt",
|
||||
"ssh.deleteConfirm": "Schlüssel \"{name}\" löschen? Server, die ihm vertrauen, akzeptieren dich von hier aus nicht mehr.",
|
||||
"chat.deleteTitle": "Chat löschen",
|
||||
"chat.deleteWithFolder": "Dieser Chat hat einen eigenen Ordner:\n{path}\n({size})\n\nSoll der Ordner mit dem Chat gelöscht werden?",
|
||||
"chat.deleteOnlyChat": "Nur Chat löschen",
|
||||
"chat.deleteChatAndFolder": "Chat und Ordner löschen",
|
||||
"chat.folderFiles": "{n} Dateien",
|
||||
"chat.folderHasGit": "Der Ordner enthält ein Git-Repository – nicht gepushte Commits und Branches gehen verloren.",
|
||||
"chat.folderDeleted": "Chat und Ordner gelöscht",
|
||||
};
|
||||
@@ -1143,6 +1143,30 @@ export const en = {
|
||||
"stats.table.agent": "by agent",
|
||||
"stats.table.user": "by user",
|
||||
"stats.table.session": "top chats",
|
||||
"command.clear": "Forget the conversation so far (the chat stays visible)",
|
||||
"chat.cleared": "Context cleared – the model no longer sees the messages above",
|
||||
"ssh.title": "SSH keys",
|
||||
"ssh.hint": "Keys that agents, the terminal and the git panel use to reach other servers (ssh, scp, rsync, git over ssh). They are used automatically; hosts are trusted on first contact. Add the public key to the server's authorized_keys.",
|
||||
"ssh.none": "No keys yet.",
|
||||
"ssh.name": "Name (e.g. laptop, build server)",
|
||||
"ssh.generate": "Generate new key",
|
||||
"ssh.import": "Import key",
|
||||
"ssh.importExisting": "Import an existing key",
|
||||
"ssh.importHint": "Private keys without a passphrase only. The key is stored on the server for your account.",
|
||||
"ssh.private": "Private key",
|
||||
"ssh.public": "Public key",
|
||||
"ssh.copyPublic": "Copy public key",
|
||||
"ssh.copied": "Public key copied",
|
||||
"ssh.copyFailed": "Could not copy – select the text manually",
|
||||
"ssh.added": "Key added",
|
||||
"ssh.deleteConfirm": "Delete the key \"{name}\"? Servers that trust it will no longer accept you from here.",
|
||||
"chat.deleteTitle": "Delete chat",
|
||||
"chat.deleteWithFolder": "This chat has a folder of its own:\n{path}\n({size})\n\nDelete the folder with the chat?",
|
||||
"chat.deleteOnlyChat": "Delete chat only",
|
||||
"chat.deleteChatAndFolder": "Delete chat and folder",
|
||||
"chat.folderFiles": "{n} files",
|
||||
"chat.folderHasGit": "The folder contains a git repository – commits and branches that were not pushed are lost.",
|
||||
"chat.folderDeleted": "Chat and folder deleted",
|
||||
};
|
||||
|
||||
export type Messages = Record<keyof typeof en, string>;
|
||||
+8
-10
@@ -600,9 +600,6 @@ button.session-group:hover {
|
||||
background: color-mix(in srgb, var(--tag) 38%, var(--bg1));
|
||||
border-color: color-mix(in srgb, var(--tag) 70%, transparent);
|
||||
}
|
||||
.chat-pane[data-color] .msg.bubble.agent {
|
||||
background: color-mix(in srgb, var(--tag) 7%, var(--bg1));
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
.msg.user-msg {
|
||||
max-width: 94%;
|
||||
@@ -4758,15 +4755,10 @@ span.chat-tab > button {
|
||||
}
|
||||
}
|
||||
|
||||
.chat-pane[data-color] {
|
||||
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
|
||||
}
|
||||
.chat-pane[data-color] .composer-wrap {
|
||||
background: color-mix(in srgb, var(--tag) 16%, var(--bg0));
|
||||
}
|
||||
/* The chat's colour marks the top bar (line), the list entry, the badge and your own bubbles –
|
||||
* the chat background itself stays neutral. */
|
||||
.topbar[data-color] {
|
||||
border-bottom: 3px solid var(--tag);
|
||||
background: color-mix(in srgb, var(--tag) 28%, var(--bg0));
|
||||
}
|
||||
.session-item[data-color] {
|
||||
box-shadow: inset 4px 0 0 var(--tag);
|
||||
@@ -5022,3 +5014,9 @@ td.fold-indent {
|
||||
.share-link input { flex: 1 1 200px; min-width: 0; }
|
||||
|
||||
.offline-bar { background: color-mix(in srgb, var(--warn, #d79921) 25%, var(--bg0, transparent)); color: var(--fg, inherit); padding: 6px 12px; font-size: 0.85rem; text-align: center; }
|
||||
|
||||
.ssh-key { display: flex; flex-direction: column; gap: 4px; margin-bottom: 10px; }
|
||||
.ssh-key .row { align-items: center; flex-wrap: wrap; }
|
||||
.ssh-key input.mono { width: 100%; font-size: 12px; }
|
||||
.dialog-message { overflow-wrap: anywhere; }
|
||||
.dialog-choices { flex-wrap: wrap; }
|
||||
@@ -3,6 +3,46 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
"202611-00039": {
|
||||
en: ["The chat colour no longer tints the chat background", "Claude Code can ask you questions again (question card in the chat)"],
|
||||
de: ["Die Chat-Farbe färbt den Hintergrund des Chats nicht mehr ein", "Claude Code kann dir wieder Fragen stellen (Fragenkarte im Chat)"],
|
||||
},
|
||||
"202611-00038": {
|
||||
en: ["Sidebar chat menu: Delete added; colour and icon are now chosen in the chat's own menu"],
|
||||
de: ["Chat-Menü in der Sidebar: Löschen hinzugefügt; Farbe und Icon wählst du jetzt im Menü des Chats"],
|
||||
},
|
||||
"202611-00037": {
|
||||
en: ["Deleting a chat now asks whether to delete its own folder too"],
|
||||
de: ["Beim Löschen eines Chats wirst du gefragt, ob sein eigener Ordner mitgelöscht werden soll"],
|
||||
},
|
||||
"202611-00036": {
|
||||
en: ["Claude Code can now use all CodeCraft tools (image generation, 3D, browser, scheduling …)"],
|
||||
de: ["Claude Code kann jetzt alle CodeCraft-Tools nutzen (Bildgenerierung, 3D, Browser, Zeitpläne …)"],
|
||||
},
|
||||
"202611-00035": {
|
||||
en: ["Creating SSH keys works in the container now (fixes \"No user exists for uid\")"],
|
||||
de: ["SSH-Schlüssel lassen sich im Container jetzt erzeugen (behebt \"No user exists for uid\")"],
|
||||
},
|
||||
"202611-00034": {
|
||||
en: ["ssh/scp/sftp now work in the container (fixes \"No user exists for uid\")"],
|
||||
de: ["ssh/scp/sftp funktionieren jetzt im Container (behebt \"No user exists for uid\")"],
|
||||
},
|
||||
"202611-00033": {
|
||||
en: ["SSH keys in Settings → Account: agents, terminal and git use them to reach other servers"],
|
||||
de: ["SSH-Schlüssel unter Einstellungen → Account: Agenten, Terminal und Git nutzen sie für den Zugriff auf andere Server"],
|
||||
},
|
||||
"202611-00032": {
|
||||
en: ["Agents now use your stored git credentials instead of searching for them (also Claude Code)"],
|
||||
de: ["Agenten nutzen deine hinterlegten Git-Zugangsdaten, statt danach zu suchen (auch Claude Code)"],
|
||||
},
|
||||
"202611-00031": {
|
||||
en: ["Images an agent reads are now shown in the chat (also with Claude Code)"],
|
||||
de: ["Bilder, die ein Agent liest, werden jetzt im Chat angezeigt (auch mit Claude Code)"],
|
||||
},
|
||||
"202611-00030": {
|
||||
en: ["/clear: the model forgets the conversation so far, the chat stays visible"],
|
||||
de: ["/clear: das Modell vergisst das bisherige Gespräch, der Chat bleibt sichtbar"],
|
||||
},
|
||||
"202611-00029": {
|
||||
en: [
|
||||
"Statistics: foldable sections, years of history with a year filter, export as XLSX/CSV",
|
||||
|
||||
Reference in new issue
Block a user