Sicherheitsrunde II: PTY-Label, Tool-Grenzen, atomare Schreibvorgänge

- term_start nur noch für das eigene term-<projekt>-Fenster — das
  (abgelöste) Panel-Fenster kann keine Shell mehr starten und beschreiben.
  Die Tauri-ACL greift für App-Commands nachweislich nicht (kein
  __app-acl__-Manifest, Prüfung gegen tauri 2.11.5); die Label-Prüfung
  ist damit die Schranke, nicht deren Verdopplung.
- archive_panel verliert das dir-Argument: Archiv-Home samt dauerhafter
  Rechtevergabe (additionalDirectories + Edit-Allow) setzt nur noch die
  UI mit Nutzer-Dialog, kein Tool-Argument des Modells.
- write_panel(path) liest nur noch aus Projekt-, Arbeits- und
  Archiv-Ordnern (canonicalize, symlink-fest), reguläre Dateien bis 2 MB
  mit hartem Byte-Cap — der promptfreie Weg umgeht das
  Read-Permission-Modell von Claude Code nicht mehr.
- write_atomic (temp + rename) für alle JSON-Config-Schreiber: Projekt-
  Config, .claude/settings.json, Registry, App-Settings, pool.json,
  Pool-settings.json, claudes .claude.json, Todo-Hook.
- write_secret_file: create_new mit 0600 statt schreiben-und-abdichten;
  kein umask-Fenster, Symlinks werden nicht gefolgt.
- PTY-Umgebung: geerbte ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN entfernt.
- panel_file & Geschwister prüfen die Projekt-ID (zweite Linie).
- Befehlskacheln filtern Bidi-/Zero-Width-Zeichen aus Anzeige und
  Zwischenablage; der Lösch-Abgleich behält den Originaltext.
This commit is contained in:
marcus hinz
2026-07-20 12:01:21 +02:00
parent 23f6562f50
commit b743de5f34
12 changed files with 196 additions and 47 deletions
+2 -2
View File
@@ -92,7 +92,7 @@ pub(crate) fn add_archive_permission(
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
/// Wählt das Archiv ab: archiveHome aus der config.json entfernen und die
@@ -128,7 +128,7 @@ fn remove_archive_permission(paths: &Paths, project: &str, dir: &str) -> Result<
allow.retain(|p| p.as_str() != Some(&edit));
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
/// Metadaten beim Archivieren: Unterordner im Archiv-Home plus Frontmatter-Felder.
+20
View File
@@ -18,6 +18,26 @@ pub(crate) mod watcher;
#[cfg(test)]
pub(crate) mod testutil;
/// Datei atomar ersetzen: temp-Datei im Zielverzeichnis, dann rename. Ein
/// Absturz oder eine volle Platte hinterlässt nie eine abgeschnittene
/// Zieldatei — wichtig für Dateien, die der App nicht allein gehören
/// (.claude/settings.json der Projekte, claudes .claude.json, Registry).
pub(crate) fn write_atomic(path: &std::path::Path, content: &str) -> Result<(), String> {
let dir = path
.parent()
.ok_or_else(|| format!("{}: kein Elternordner", path.display()))?;
let name = path
.file_name()
.and_then(|n| n.to_str())
.ok_or_else(|| format!("{}: kein Dateiname", path.display()))?;
let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id()));
std::fs::write(&tmp, content).map_err(|e| format!("{}: {e}", tmp.display()))?;
std::fs::rename(&tmp, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp);
format!("{}: {e}", path.display())
})
}
/// Namensprüfung für Projekte und Pool-Anzeigenamen.
///
/// Der Name wird als Pfadsegment verwendet (Pool-Verzeichnis, Panel-Dateien)
+12 -4
View File
@@ -50,10 +50,18 @@ impl Paths {
}
}
/// Zweite Linie: Die Projekt-ID wird zum Dateinamen der Panel-Kanäle — ein
/// Wert mit Pfad-Bestandteilen (etwa aus einer verfälschten Registry oder
/// AI_CONTROL_PROJECT-Env) bricht hier laut ab, statt einen Pfad zu bilden.
fn checked(project: &str) -> &str {
crate::domain::check_name(project).unwrap();
project
}
/// Panel-Datei eines Projekts (Kanal Skill -> Panel). Der Pfad landet als
/// AI_CONTROL_PANEL in der PTY-Umgebung.
pub(crate) fn panel_file(project: &str) -> PathBuf {
Paths::real().panels_dir().join(format!("{project}.md"))
Paths::real().panels_dir().join(format!("{}.md", checked(project)))
}
/// Command-History eines Projekts (JSONL, anhängend — flüchtig, wird beim
@@ -62,7 +70,7 @@ pub(crate) fn panel_file(project: &str) -> PathBuf {
pub(crate) fn commands_file(project: &str) -> PathBuf {
Paths::real()
.panels_dir()
.join(format!("{project}.commands.jsonl"))
.join(format!("{}.commands.jsonl", checked(project)))
}
/// Suchtreffer-Datei eines Projekts (JSON, letzter search_archive-Aufruf —
@@ -71,7 +79,7 @@ pub(crate) fn commands_file(project: &str) -> PathBuf {
pub(crate) fn search_file(project: &str) -> PathBuf {
Paths::real()
.panels_dir()
.join(format!("{project}.search.json"))
.join(format!("{}.search.json", checked(project)))
}
/// Wiki-Puffer eines Projekts (JSON, jeweils letzte Wiki-Seite bzw. letztes
@@ -80,7 +88,7 @@ pub(crate) fn search_file(project: &str) -> PathBuf {
pub(crate) fn wiki_file(project: &str) -> PathBuf {
Paths::real()
.panels_dir()
.join(format!("{project}.wiki.json"))
.join(format!("{}.wiki.json", checked(project)))
}
/// "~" bzw. "~/x" relativ zum Home auflösen; alles andere unverändert.
+5 -7
View File
@@ -143,8 +143,7 @@ fn write_pool_json(dir: &PathBuf, name: &str, credential_type: &str) -> Result<(
credential_type: credential_type.to_string(),
};
let raw = serde_json::to_string_pretty(&pool).map_err(|e| e.to_string())?;
fs::write(dir.join(POOL_FILE), raw + "\n")
.map_err(|e| format!("{}: {e}", dir.join(POOL_FILE).display()))
crate::domain::write_atomic(&dir.join(POOL_FILE), &(raw + "\n"))
}
/// Grundausstattung eines Pool-Ordners (= CLAUDE_CONFIG_DIR): settings.json
@@ -169,8 +168,7 @@ pub(crate) fn init_pool_config(
}
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
fs::write(dir.join("settings.json"), raw + "\n")
.map_err(|e| format!("{}: {e}", dir.join("settings.json").display()))?;
crate::domain::write_atomic(&dir.join("settings.json"), &(raw + "\n"))?;
let claude_md = dir.join("CLAUDE.md");
if !claude_md.is_file() {
fs::write(&claude_md, "").map_err(|e| format!("{}: {e}", claude_md.display()))?;
@@ -245,7 +243,7 @@ fn ensure_panel_permission(pool_dir: &std::path::Path) {
return; // nichts geändert
}
if let Ok(out) = serde_json::to_string_pretty(&v) {
let _ = fs::write(&sp, out + "\n");
let _ = crate::domain::write_atomic(&sp, &(out + "\n"));
}
}
@@ -294,7 +292,7 @@ fn register_mcp_server(pool_dir: &std::path::Path) {
servers.remove("aicontrol"); // alter Key vor Umbenennung
servers.insert(PANEL_MCP_SERVER.into(), desired);
if let Ok(out) = serde_json::to_string_pretty(&v) {
let _ = fs::write(&cfg, out + "\n");
let _ = crate::domain::write_atomic(&cfg, &(out + "\n"));
}
}
@@ -477,7 +475,7 @@ pub(crate) fn set_apikey_in(
settings["apiKeyHelper"] =
serde_json::json!(crate::platform::apikey_helper_command(&dir, pool));
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
fs::write(&settings_path, raw + "\n").map_err(|e| format!("{}: {e}", settings_path.display()))
crate::domain::write_atomic(&settings_path, &(raw + "\n"))
}
pub(crate) fn ensure_oauth_pool(paths: &Paths, pool: &str) -> Result<(), String> {
+84 -8
View File
@@ -119,7 +119,7 @@ pub(crate) fn write_project_config_in(
let dir = project_dir(paths, project)?.join(PROJECT_CONFIG_DIR);
fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
let path = dir.join(PROJECT_FILE);
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
crate::domain::write_atomic(&path, &(raw + "\n"))
}
pub(crate) fn is_running(project: &str) -> bool {
@@ -292,8 +292,7 @@ pub(crate) fn create_project_full_in(
},
});
let raw = serde_json::to_string_pretty(&settings).map_err(|e| e.to_string())?;
fs::write(dir.join(".claude").join("settings.json"), raw + "\n")
.map_err(|e| format!("{}: {e}", dir.join(".claude").join("settings.json").display()))?;
crate::domain::write_atomic(&dir.join(".claude").join("settings.json"), &(raw + "\n"))?;
let id = uuid::Uuid::new_v4().to_string();
reg.insert(id.clone(), RegEntry { dir: dir.clone(), pool: pool.map(str::to_string) });
@@ -357,7 +356,7 @@ pub(crate) fn set_project_dir_in(paths: &Paths, name: &str, dir: &str) -> Result
}
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
/// Nimmt einen bestehenden Ordner als Projekt auf. Eine mitgebrachte
@@ -449,7 +448,7 @@ pub(crate) fn add_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result<()
let parent = sp.parent().ok_or("settings.json ohne Elternordner")?;
fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
/// Nimmt einen Arbeitsordner wieder raus: additionalDirectories-Eintrag und
@@ -475,7 +474,7 @@ pub(crate) fn remove_work_dir_in(paths: &Paths, name: &str, dir: &str) -> Result
allow.retain(|p| p.as_str() != Some(&format!("Edit({dir}/**)")));
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
/// Arbeitsordner des Projekts: additionalDirectories aus der Projekt-settings.json.
@@ -588,6 +587,49 @@ pub(crate) fn resolve_icon_path(
}
}
/// Liest eine Datei für `write_panel(path)` — den promptfreien MCP-Weg.
/// Erlaubt ist genau, was die Session über ihre Permissions ohnehin promptfrei
/// liest: Projektordner, Arbeitsordner und Archiv-Home. Geprüft wird nach
/// `canonicalize` (symlink-fest), nur reguläre Dateien bis 2 MB — sonst
/// umginge das Tool das Read-Permission-Modell von Claude Code, und ein
/// Device wie /dev/zero hinge den Prozess.
pub(crate) fn read_for_panel_in(
paths: &Paths,
project: &str,
src: &str,
) -> Result<String, String> {
const MAX: u64 = 2 * 1024 * 1024;
let canon = fs::canonicalize(expand_home(paths, src)).map_err(|e| format!("{src}: {e}"))?;
let mut roots = vec![project_dir(paths, project)?];
for d in project_work_dirs_in(paths, project)? {
roots.push(expand_home(paths, &d));
}
if let Some(a) = read_project_config_in(paths, project)?.archive_home {
roots.push(expand_home(paths, &a));
}
let allowed = roots
.iter()
.filter_map(|r| fs::canonicalize(r).ok())
.any(|r| canon.starts_with(&r));
if !allowed {
return Err(format!(
"{src}: liegt außerhalb von Projekt-, Arbeits- und Archiv-Ordnern"
));
}
let f = fs::File::open(&canon).map_err(|e| format!("{src}: {e}"))?;
let meta = f.metadata().map_err(|e| format!("{src}: {e}"))?;
if !meta.is_file() {
return Err(format!("{src}: keine reguläre Datei"));
}
if meta.len() > MAX {
return Err(format!("{src}: größer als 2 MB"));
}
let mut text = String::new();
std::io::Read::read_to_string(&mut std::io::Read::take(f, MAX), &mut text)
.map_err(|e| format!("{src}: {e}"))?;
Ok(text)
}
/// Migration beim App-Start (idempotent): ai-control.json aus dem Projekt-Root
/// nach .ai-control/config.json, die Pool-Zuordnung daraus in die Registry,
/// Projekt-Icons aus dem zentralen Icons-Verzeichnis in den Projekt-Config-Ordner.
@@ -615,7 +657,7 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
if !cfg_path.exists() && !obj.is_empty() {
fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?;
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&cfg_path, raw + "\n").map_err(|e| format!("{}: {e}", cfg_path.display()))?;
crate::domain::write_atomic(&cfg_path, &(raw + "\n"))?;
}
fs::remove_file(&legacy).map_err(|e| format!("{}: {e}", legacy.display()))?;
}
@@ -660,7 +702,7 @@ pub(crate) fn migrate_layout_in(paths: &Paths) -> Result<(), String> {
if cfg_dirty {
fs::create_dir_all(&cfg_dir).map_err(|e| format!("{}: {e}", cfg_dir.display()))?;
let raw = serde_json::to_string_pretty(&cfg).map_err(|e| e.to_string())?;
fs::write(&cfg_path, raw + "\n").map_err(|e| format!("{}: {e}", cfg_path.display()))?;
crate::domain::write_atomic(&cfg_path, &(raw + "\n"))?;
}
if id != key {
reg_dirty = true;
@@ -1200,6 +1242,40 @@ mod tests {
assert!(!raw.contains("claude-projects/proj"));
}
// -- write_panel(path): promptfreier Lese-Weg --
#[test]
fn panel_lesen_nur_aus_projekt_arbeits_und_archivordnern() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
let dir = p.projects_dir().join("proj");
fs::write(dir.join("notiz.md"), "inhalt").unwrap();
assert_eq!(read_for_panel_in(&p, "proj", &dir.join("notiz.md").display().to_string()).unwrap(), "inhalt");
// außerhalb: abgelehnt
fs::write(p.home.join("geheim.txt"), "x").unwrap();
let err = read_for_panel_in(&p, "proj", &p.home.join("geheim.txt").display().to_string())
.unwrap_err();
assert!(err.contains("außerhalb"));
// Symlink aus dem Projekt hinaus: canonicalize entlarvt das Ziel
std::os::unix::fs::symlink(p.home.join("geheim.txt"), dir.join("link.txt")).unwrap();
assert!(read_for_panel_in(&p, "proj", &dir.join("link.txt").display().to_string()).is_err());
}
#[test]
fn panel_lesen_groessenlimit_und_geraetedateien() {
let p = tmp_paths();
create_project(&p, "proj").unwrap();
let dir = p.projects_dir().join("proj");
fs::write(dir.join("gross.bin"), vec![b'x'; 2 * 1024 * 1024 + 1]).unwrap();
let err = read_for_panel_in(&p, "proj", &dir.join("gross.bin").display().to_string())
.unwrap_err();
assert!(err.contains("2 MB"));
// Geräte-Datei liegt eh außerhalb der Wurzeln — der Ablehnungsgrund davor
assert!(read_for_panel_in(&p, "proj", "/dev/zero").is_err());
}
#[test]
fn projekt_loeschen_unbekannt_scheitert() {
let p = tmp_paths();
+1 -1
View File
@@ -72,7 +72,7 @@ pub(crate) fn save_registry(
fs::create_dir_all(paths.config_dir())
.map_err(|e| format!("{}: {e}", paths.config_dir().display()))?;
let file = paths.projects_file();
fs::write(&file, raw + "\n").map_err(|e| format!("{}: {e}", file.display()))
crate::domain::write_atomic(&file, &(raw + "\n"))
}
/// Ordner eines registrierten Projekts.
+1 -1
View File
@@ -73,7 +73,7 @@ fn write_app_setting(
fs::create_dir_all(paths.config_dir())
.map_err(|e| format!("{}: {e}", paths.config_dir().display()))?;
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&path, raw + "\n").map_err(|e| format!("{}: {e}", path.display()))
crate::domain::write_atomic(&path, &(raw + "\n"))
}
#[cfg(test)]
+1 -1
View File
@@ -102,7 +102,7 @@ pub(crate) fn set_todo_in(paths: &Paths, name: &str, enabled: bool) -> Result<()
}
let raw = serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?;
fs::write(&sp, raw + "\n").map_err(|e| format!("{}: {e}", sp.display()))
crate::domain::write_atomic(&sp, &(raw + "\n"))
}
#[cfg(test)]
+22 -17
View File
@@ -68,7 +68,9 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
ist. Statt den Text zusätzlich als Fließtext auszugeben, dieses Tool \
aufrufen und im Chat nur kurz bestätigen. Für eine bestehende Datei \
IMMER `path` statt `text` übergeben — der Server liest die Datei \
selbst von der Platte, ohne dass ihr Inhalt generiert werden muss.",
selbst von der Platte, ohne dass ihr Inhalt generiert werden muss. \
`path` muss im Projekt-, Arbeits- oder Archiv-Ordner liegen \
(reguläre Datei, max. 2 MB).",
"inputSchema": {
"type": "object",
"properties": {
@@ -136,18 +138,11 @@ fn handle(method: &str, req: &Value) -> Option<Value> {
(Nutzer sagt etwa „archiviere das“). Beim Archivieren `folder`, \
`description` und `tags` mitgeben — einmalige Kuratierung im Moment \
des Archivierens, landet im Frontmatter. Ist kein Archiv-Home \
konfiguriert, den Zielpfad im Argument `dir` mitgeben (der Nutzer \
nennt ihn); ohne `dir` und ohne konfiguriertes Home meldet das Tool \
das zurück.",
konfiguriert, meldet das Tool das zurück; gesetzt wird es in den \
Projekt-Einstellungen oder im Panel.",
"inputSchema": {
"type": "object",
"properties": {
"dir": {
"type": "string",
"description":
"Optionales Archiv-Home (absoluter Pfad). Wird gesetzt und für \
künftige Archivierungen gemerkt.",
},
"folder": {
"type": "string",
"description":
@@ -253,11 +248,18 @@ fn call_write(req: &Value) -> Value {
let args = &req["params"]["arguments"];
// `path` lädt eine vorhandene Datei serverseitig — der schnelle Weg, ohne
// dass das Modell den Inhalt Token für Token als `text` generieren muss.
// Der Zugriff ist auf Projekt-, Arbeits- und Archiv-Ordner begrenzt
// (read_for_panel_in): das Tool ist promptfrei freigegeben und darf das
// Read-Permission-Modell von Claude Code nicht umgehen.
let (text, ok_msg) = match args["path"].as_str() {
Some(src) => match std::fs::read_to_string(src) {
Ok(content) => (content, "Datei ins Panel geladen."),
Err(e) => return err(format!("Datei {src} nicht lesbar: {e}")),
},
Some(src) => {
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
let paths = crate::domain::paths::Paths::real();
match crate::domain::project::read_for_panel_in(&paths, &project, src) {
Ok(content) => (content, "Datei ins Panel geladen."),
Err(e) => return err(format!("Datei nicht geladen: {e}")),
}
}
None => (
args["text"].as_str().unwrap_or("").to_string(),
"Entwurf ins Panel geschrieben.",
@@ -323,10 +325,13 @@ fn call_show_commands() -> Value {
}
}
/// `dir` gibt es hier bewusst nicht mehr: Das Setzen des Archiv-Homes vergibt
/// über `add_archive_permission` dauerhafte Rechte in der settings.json des
/// Projekts (additionalDirectories + Edit-Allow) — das bleibt der UI mit
/// Nutzer-Dialog vorbehalten, nicht einem Tool-Argument des Modells.
fn call_archive(req: &Value) -> Value {
let project = std::env::var("AI_CONTROL_PROJECT").unwrap_or_default();
let args = &req["params"]["arguments"];
let dir = args["dir"].as_str();
let meta = crate::domain::archive::ArchiveMeta {
folder: args["folder"].as_str().map(str::to_string),
description: args["description"].as_str().map(str::to_string),
@@ -335,10 +340,10 @@ fn call_archive(req: &Value) -> Value {
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect())
.unwrap_or_default(),
};
match crate::domain::archive::archive_panel_content(&project, dir, &meta) {
match crate::domain::archive::archive_panel_content(&project, None, &meta) {
Ok(path) => ok(format!("Archiviert: {}", path.display())),
Err(e) => err(format!(
"Nicht archiviert: {e}. Zielordner im Argument `dir` angeben oder im Panel wählen."
"Nicht archiviert: {e}. Archiv-Ordner im Panel oder in den Projekt-Einstellungen wählen."
)),
}
}
+19 -3
View File
@@ -1,6 +1,5 @@
//! Unix-Gemeinsames (macOS + Linux): Prozesse, Shell, Dateirechte, Symlinks.
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -45,12 +44,29 @@ pub(crate) fn kill_terminal(pid: u32) -> Result<(), String> {
}
}
/// Secret-Datei von Anfang an mit 0600 anlegen: `create_new` + Modus statt
/// schreiben-und-nachträglich-abdichten — so gibt es kein Fenster, in dem die
/// Datei mit umask-Rechten lesbar ist, und ein untergeschobener Symlink wird
/// nicht gefolgt (die alte Datei wird vorher entfernt, `create_new` lehnt
/// Vorhandenes ab).
pub(crate) fn write_secret_file(path: &Path, content: &str) -> Result<(), String> {
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
}
std::fs::write(path, content).map_err(|e| format!("{}: {e}", path.display()))?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
match std::fs::remove_file(path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("{}: {e}", path.display())),
}
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
.map_err(|e| format!("{}: {e}", path.display()))?;
f.write_all(content.as_bytes())
.map_err(|e| format!("{}: {e}", path.display()))
}
+14
View File
@@ -102,6 +102,12 @@ pub fn build_window(
/// (settings.json: claudeCommand, Default claude) im Projektordner, mit dem
/// Pool-Verzeichnis als CLAUDE_CONFIG_DIR. Die Login-Shell aus $SHELL (-l)
/// baut den PATH aus ihren Profil-Dateien auf — shell-agnostisch.
///
/// Nur das Terminal-Fenster selbst darf seine PTY starten: Die Terminals-Map
/// ist über das Fensterlabel adressiert, und `term_write` schreibt danach in
/// die laufende Shell. Ohne die Label-Prüfung könnte auch das abgelöste
/// Panel-Fenster — und damit jedes Skript in dessen Webview — eine Shell
/// starten und beschreiben.
#[tauri::command]
pub fn term_start(
window: tauri::WebviewWindow,
@@ -110,6 +116,9 @@ pub fn term_start(
rows: u16,
cols: u16,
) -> Result<(), String> {
if window.label() != format!("term-{project}") {
return Err(format!("PTY nur für das Terminal-Fenster: {}", window.label()));
}
let paths = Paths::real();
let cwd = project_dir(&paths, &project)?;
@@ -143,6 +152,11 @@ pub fn term_start(
let mut cmd = crate::platform::shell_command(&claude_command(&paths));
cmd.cwd(&cwd);
// Aus der App-Umgebung geerbte Anthropic-Credentials rausnehmen — ein
// ANTHROPIC_API_KEY sticht sonst den apiKeyHelper des Pools. Variablen, die
// erst das Shell-Profil der Login-Shell setzt, erreicht das nicht.
cmd.env_remove("ANTHROPIC_API_KEY");
cmd.env_remove("ANTHROPIC_AUTH_TOKEN");
cmd.env("TERM", "xterm-256color");
cmd.env("AI_CONTROL_PANEL", &panel_path);
cmd.env("AI_CONTROL_COMMANDS", &commands_path);
+15 -3
View File
@@ -7,6 +7,13 @@
import { writeText } from "@tauri-apps/plugin-clipboard-manager";
/// Entfernt Bidi- und Zero-Width-Steuerzeichen (U+200B200F, U+202A202E,
/// U+20602064, U+20662069, U+FEFF) aus Befehlstexten.
function stripInvisibles(s: string): string {
return s.replace(/[\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g, "");
}
interface CommandEntry {
cmd: string;
note?: string;
@@ -106,7 +113,7 @@ export function initCommandsView(
all.className = "cmd-all";
all.textContent = "Alle kopieren";
all.addEventListener("click", async () => {
await writeText(cmds.map((c) => c.cmd).join("\n"));
await writeText(cmds.map((c) => stripInvisibles(c.cmd)).join("\n"));
flash(all, "copied");
});
head.append(all);
@@ -114,13 +121,18 @@ export function initCommandsView(
block.append(head);
cmds.forEach((entry, j) => {
// Unsichtbare Steuerzeichen (Bidi, Zero-Width) aus Anzeige UND
// Kopie halten — sonst sieht der Nutzer einen anderen Befehl, als
// die Zwischenablage enthält. Der Lösch-Abgleich läuft weiter über
// den Original-String.
const visible = stripInvisibles(entry.cmd);
const tile = document.createElement("div");
tile.className = "cmd-tile";
const body = document.createElement("div");
body.className = "cmd-body";
const cmd = document.createElement("div");
cmd.className = "cmd-text";
cmd.textContent = entry.cmd;
cmd.textContent = visible;
body.append(cmd);
if (entry.note) {
const note = document.createElement("div");
@@ -128,7 +140,7 @@ export function initCommandsView(
note.textContent = entry.note;
body.append(note);
}
tile.append(body, copyBtn(() => entry.cmd), deleteBtn(() => onDelete(i, j, entry.cmd)));
tile.append(body, copyBtn(() => visible), deleteBtn(() => onDelete(i, j, entry.cmd)));
block.append(tile);
});
container.append(block);