diff --git a/CLAUDE.md b/CLAUDE.md index 013460a..6040fd9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -302,7 +302,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li approach converges on the worst state: a 400 MB browser present with its libraries gone. - The layer sits immediately after Node (npx is its only prerequisite) and well above the shim `COPY`s, so editing a shim does not re-run a multi-hundred-megabyte apt install. -- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity` +- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup (`sync_host_ssh` *merges* the host dir into `~/.ssh`; never wipe it — container-made keys live there on the home volume), git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity` - **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations **`/home/claude` in the image is seed-only.** It is the mount point of the named volume diff --git a/app/src-tauri/src/commands/settings_commands.rs b/app/src-tauri/src/commands/settings_commands.rs index aa83603..850de0a 100644 --- a/app/src-tauri/src/commands/settings_commands.rs +++ b/app/src-tauri/src/commands/settings_commands.rs @@ -36,7 +36,7 @@ pub fn validate_settings_update( // fallback for **every** project without an override // (`container.rs`'s `create_container`), so `/` here read-only bind-mounts // the whole host at `/tmp/.host-ssh` for all of them — and `entrypoint.sh` - // then does `cp -a /tmp/.host-ssh ~/.ssh`, recursively copying it into the + // then merges `/tmp/.host-ssh` into `~/.ssh`, recursively copying it into the // home volume this release exists to bound. // // Grandfathered the same way project paths are: a value carried over diff --git a/app/src-tauri/src/docker/image.rs b/app/src-tauri/src/docker/image.rs index 7aac124..7a664b3 100644 --- a/app/src-tauri/src/docker/image.rs +++ b/app/src-tauri/src/docker/image.rs @@ -205,3 +205,137 @@ fn create_build_context() -> Result, std::io::Error> { let _ = buf.flush(); Ok(buf) } + +#[cfg(all(test, unix))] +mod tests { + use super::ENTRYPOINT; + use std::fs; + use std::path::Path; + use std::process::Command; + + /// The `sync_host_ssh` function from `entrypoint.sh`, cut out between its + /// markers so it can be run against temp dirs instead of the real ~/.ssh. + fn sync_fn() -> &'static str { + let start = ENTRYPOINT.find("# >>> sync_host_ssh").expect("start marker"); + let end = ENTRYPOINT.find("# <<< sync_host_ssh").expect("end marker"); + &ENTRYPOINT[start..end] + } + + fn sync(src: &Path, dest: &Path) -> String { + let script = format!("{}\nsync_host_ssh \"$1\" \"$2\"\n", sync_fn()); + let out = Command::new("bash") + .arg("-c") + .arg(script) + .arg("sync") + .arg(src) + .arg(dest) + .output() + .expect("run bash"); + assert!(out.status.success(), "sync failed: {}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).into_owned() + } + + fn read(p: &Path) -> String { + fs::read_to_string(p).unwrap() + } + + #[test] + fn container_created_keys_survive_restart() { + let tmp = tempfile::tempdir().unwrap(); + let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh")); + fs::create_dir_all(&src).unwrap(); + fs::write(src.join("id_host"), "host-key").unwrap(); + + sync(&src, &dest); + fs::write(dest.join("id_container"), "made-in-container").unwrap(); + sync(&src, &dest); + + assert_eq!(read(&dest.join("id_container")), "made-in-container"); + assert_eq!(read(&dest.join("id_host")), "host-key"); + } + + #[test] + fn container_file_wins_over_same_named_host_file() { + let tmp = tempfile::tempdir().unwrap(); + let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh")); + fs::create_dir_all(&src).unwrap(); + fs::write(src.join("id_host"), "host-key").unwrap(); + sync(&src, &dest); + + fs::write(dest.join("id_ed25519"), "container-key").unwrap(); + fs::write(src.join("id_ed25519"), "host-ed25519").unwrap(); + let out = sync(&src, &dest); + + assert_eq!(read(&dest.join("id_ed25519")), "container-key"); + assert!(out.contains("keeping container's"), "{out}"); + } + + #[test] + fn host_changes_propagate_until_edited_in_container() { + let tmp = tempfile::tempdir().unwrap(); + let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh")); + fs::create_dir_all(&src).unwrap(); + fs::write(src.join("config"), "v1").unwrap(); + fs::write(src.join("id_old"), "old").unwrap(); + sync(&src, &dest); + + fs::write(src.join("config"), "v2").unwrap(); + fs::remove_file(src.join("id_old")).unwrap(); + sync(&src, &dest); + assert_eq!(read(&dest.join("config")), "v2"); + assert!(!dest.join("id_old").exists(), "host-removed, unedited file is dropped"); + + fs::write(dest.join("config"), "edited in container").unwrap(); + fs::write(src.join("config"), "v3").unwrap(); + sync(&src, &dest); + assert_eq!(read(&dest.join("config")), "edited in container"); + + fs::remove_file(src.join("config")).unwrap(); + sync(&src, &dest); + assert_eq!(read(&dest.join("config")), "edited in container"); + } + + #[test] + fn known_hosts_is_unioned() { + let tmp = tempfile::tempdir().unwrap(); + let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh")); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&dest).unwrap(); + fs::write(src.join("known_hosts"), "hostA key\n").unwrap(); + fs::write(dest.join("known_hosts"), "containerB key\n").unwrap(); + + sync(&src, &dest); + let kh = read(&dest.join("known_hosts")); + assert!(kh.contains("hostA key") && kh.contains("containerB key"), "{kh}"); + } + + #[test] + fn upgrade_from_wipe_and_copy_backs_up_differing_files() { + // No manifest yet: the state an existing project is in on its first + // start with this entrypoint. + let tmp = tempfile::tempdir().unwrap(); + let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh")); + fs::create_dir_all(&src).unwrap(); + fs::create_dir_all(&dest).unwrap(); + fs::write(src.join("id_rsa"), "host").unwrap(); + fs::write(dest.join("id_rsa"), "container").unwrap(); + fs::write(dest.join("id_extra"), "extra").unwrap(); + + sync(&src, &dest); + + assert_eq!(read(&dest.join("id_rsa")), "host"); + assert_eq!(read(&dest.join("id_rsa.triple-c-backup")), "container"); + assert_eq!(read(&dest.join("id_extra")), "extra"); + } + + #[test] + fn nested_host_files_are_copied() { + let tmp = tempfile::tempdir().unwrap(); + let (src, dest) = (tmp.path().join("host"), tmp.path().join("ssh")); + fs::create_dir_all(src.join("config.d")).unwrap(); + fs::write(src.join("config.d/work"), "Host work").unwrap(); + + sync(&src, &dest); + assert_eq!(read(&dest.join("config.d/work")), "Host work"); + } +} diff --git a/container/entrypoint.sh b/container/entrypoint.sh index 5480a4c..41e4438 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -220,14 +220,100 @@ install_corporate_ca() { install_corporate_ca # ── SSH key setup ────────────────────────────────────────────────────────── -# Host SSH dir is mounted read-only at /tmp/.host-ssh. -# Copy to /home/claude/.ssh so we can fix permissions. +# Host SSH dir is mounted read-only at /tmp/.host-ssh and merged into +# /home/claude/.ssh (which lives on the home volume) so we can fix permissions. +# +# This used to `rm -rf ~/.ssh` and copy the host dir over it on every start, +# which silently deleted any key generated or saved inside the container and +# overwrote container edits to config/known_hosts. Now it is a merge: +# - Files the container created are never touched, even if the host later +# gains a file of the same name (the host copy is skipped with a warning). +# - A file previously copied from the host is refreshed when the host copy +# changes, and removed when the host drops it — but only while it is still +# byte-identical to what we copied. Once edited in the container it is the +# container's. +# - known_hosts is unioned, never replaced (ssh appends to it at runtime). +# `.triple-c-host-files` records "\t" for every file we +# copied, which is how the two kinds of file are told apart. Without it (first +# start after upgrading from the wipe-and-copy entrypoint) a differing file is +# backed up to `.triple-c-backup` before the host copy replaces it. +# +# The function body between the markers is executed by a unit test in +# app/src-tauri/src/docker/image.rs; keep it self-contained. +# >>> sync_host_ssh +sync_host_ssh() { + local src="$1" dest="$2" + local manifest="$dest/.triple-c-host-files" + local legacy=0 new_manifest f rel target src_sum cur_sum recorded + + _ssh_sum() { + if [ -L "$1" ]; then + printf 'link:%s' "$(readlink "$1")" | sha256sum | cut -d' ' -f1 + else + sha256sum < "$1" | cut -d' ' -f1 + fi + } + + mkdir -p "$dest" + [ -f "$manifest" ] || legacy=1 + new_manifest=$(mktemp) + + while IFS= read -r -d '' f; do + rel="${f#"$src"/}" + [ "$rel" = "known_hosts" ] && continue + target="$dest/$rel" + src_sum=$(_ssh_sum "$f") + if [ -e "$target" ] || [ -L "$target" ]; then + cur_sum=$(_ssh_sum "$target") + if [ "$cur_sum" != "$src_sum" ]; then + recorded="" + [ "$legacy" = 0 ] && recorded=$(awk -F'\t' -v r="$rel" '$2 == r { print $1; exit }' "$manifest") + if [ "$legacy" = 1 ]; then + cp -a "$target" "$target.triple-c-backup" + echo "entrypoint: ~/.ssh/$rel differs from host copy; kept old version as $rel.triple-c-backup" + elif [ -z "$recorded" ] || [ "$recorded" != "$cur_sum" ]; then + echo "entrypoint: keeping container's ~/.ssh/$rel (host copy not applied)" + continue + fi + rm -f "$target" + mkdir -p "$(dirname "$target")" + cp -a "$f" "$target" + fi + else + mkdir -p "$(dirname "$target")" + cp -a "$f" "$target" + fi + printf '%s\t%s\n' "$src_sum" "$rel" >> "$new_manifest" + done < <(find "$src" \( -type f -o -type l \) -print0) + + # Drop files the host no longer provides, unless edited in the container. + if [ "$legacy" = 0 ]; then + while IFS=$'\t' read -r recorded rel; do + [ -n "$rel" ] || continue + if [ ! -e "$src/$rel" ] && [ ! -L "$src/$rel" ] \ + && { [ -e "$dest/$rel" ] || [ -L "$dest/$rel" ]; } \ + && [ "$(_ssh_sum "$dest/$rel")" = "$recorded" ]; then + rm -f "$dest/$rel" + fi + done < "$manifest" + fi + + if [ -f "$src/known_hosts" ]; then + cat "$src/known_hosts" >> "$dest/known_hosts" + sort -u -o "$dest/known_hosts" "$dest/known_hosts" + fi + + mv "$new_manifest" "$manifest" + unset -f _ssh_sum +} +# <<< sync_host_ssh + if [ -d /tmp/.host-ssh ]; then - rm -rf /home/claude/.ssh - cp -a /tmp/.host-ssh /home/claude/.ssh + sync_host_ssh /tmp/.host-ssh /home/claude/.ssh chown -R claude:claude /home/claude/.ssh chmod 700 /home/claude/.ssh find /home/claude/.ssh -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; + find /home/claude/.ssh -type f -name "*.triple-c-backup" -exec chmod 600 {} \; find /home/claude/.ssh -type f -name "*.pub" -exec chmod 644 {} \; if [ -f /home/claude/.ssh/known_hosts ]; then chmod 644 /home/claude/.ssh/known_hosts