Keep container SSH keys across restarts #66

Merged
jknapp merged 1 commits from fix/persist-container-ssh-keys into main 2026-10-16 19:46:23 +00:00
4 changed files with 226 additions and 6 deletions

No files matched your search

+1 -1
View File
@@ -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
@@ -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
+134
View File
@@ -205,3 +205,137 @@ fn create_build_context() -> Result<Vec<u8>, 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");
}
}
+90 -4
View File
@@ -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 "<sha256>\t<relative path>" 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 `<name>.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