From 862af0583bae12b3119dd38fe8a67b4572058817 Mon Sep 17 00:00:00 2001 From: Sam & Claude Date: Mon, 22 Jun 2026 09:55:56 +0200 Subject: [PATCH] feat(seed): outbound SSH client material for hands-free node->mother MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The baked mother key (build/mother-ssh-key) puts a private key in the image, which only works for a non-published personalized stick. The offline FAT32 seed is the correct home for per-node secrets. Teach the importer to install outbound SSH client material from an agent's ssh/ dir into the agent home: - config -> ~/.ssh/config (0600) - known_hosts* -> ~/.ssh/known_hosts* (0644, merged + de-duped) - .pub -> ~/.ssh/.pub (0644) - -> ~/.ssh/ (0600, any other file = private key) authorized_keys stays inbound-only via _seed_install_authorized_keys. This closes the 'without manual key exchange' gap: known_hosts pins mother's host key so the first node->mother connect does not prompt, and the private client key rides on the offline seed instead of the base image — so the published image stays secret-free. Supersedes the baked-key path (#112), which can retire once this is validated on hardware. Verified offline (CLAWDIE_SEED_TEST): correct perms (key 0600, pub/known_hosts 0644, config 0600, .ssh 0700) and idempotent known_hosts merge across re-runs. Co-Authored-By: Claude Opus 4.8 --- live/operator-session/clawdie-live-seed | 64 +++++++++++++++++++ .../clawdie-live-seed.README.txt | 28 +++++++- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/live/operator-session/clawdie-live-seed b/live/operator-session/clawdie-live-seed index 756215f..111b633 100644 --- a/live/operator-session/clawdie-live-seed +++ b/live/operator-session/clawdie-live-seed @@ -112,6 +112,65 @@ _seed_install_authorized_keys() { _seed_log "installed authorized_keys from ${_src} -> ${_dst}" } +# Install OUTBOUND ssh client material from an agent's ssh/ dir into the agent +# home so a seeded node can SSH out (e.g. node -> mother) hands-free, with the +# private key delivered on the offline seed instead of baked into the image: +# config -> ~/.ssh/config (0600; e.g. a "Host mother" alias) +# known_hosts* -> ~/.ssh/known_hosts* (0644; merged, so mother's host key +# is trusted and the first connect +# does not prompt) +# .pub -> ~/.ssh/.pub (0644) +# -> ~/.ssh/ (0600; any other file = private key) +# authorized_keys is INBOUND and handled by _seed_install_authorized_keys. +_seed_install_ssh_material() { + _srcdir="$1" + _user="${2:-${SEED_USER}}" + _home="${3:-${SEED_USER_HOME}}" + + [ -d "${_srcdir}" ] || return 0 + + _ssh_dir="${_home}/.ssh" + mkdir -p "${_ssh_dir}" + chown "${_user}:${_user}" "${_ssh_dir}" 2>/dev/null || true + chmod 0700 "${_ssh_dir}" + + for _f in "${_srcdir}"/*; do + [ -f "${_f}" ] || continue + _base="$(basename "${_f}")" + _dst="${_ssh_dir}/${_base}" + case "${_base}" in + authorized_keys) + continue ;; # inbound, installed separately + known_hosts|known_hosts2) + touch "${_dst}" + tr -d '\r' <"${_f}" >>"${_dst}" + # De-dup so re-imports stay idempotent (order is irrelevant here). + sort -u "${_dst}" -o "${_dst}" 2>/dev/null || true + chmod 0644 "${_dst}" + chown "${_user}:${_user}" "${_dst}" 2>/dev/null || true + _seed_log "merged ${_base} -> ${_dst}" ;; + config) + tr -d '\r' <"${_f}" >"${_dst}.new" + mv -f "${_dst}.new" "${_dst}" + chmod 0600 "${_dst}" + chown "${_user}:${_user}" "${_dst}" 2>/dev/null || true + _seed_log "installed ssh config -> ${_dst}" ;; + *.pub) + tr -d '\r' <"${_f}" >"${_dst}.new" + mv -f "${_dst}.new" "${_dst}" + chmod 0644 "${_dst}" + chown "${_user}:${_user}" "${_dst}" 2>/dev/null || true + _seed_log "installed public key -> ${_dst}" ;; + *) + tr -d '\r' <"${_f}" >"${_dst}.new" + mv -f "${_dst}.new" "${_dst}" + chmod 0600 "${_dst}" + chown "${_user}:${_user}" "${_dst}" 2>/dev/null || true + _seed_log "installed private key -> ${_dst}" ;; + esac + done +} + # Valid POSIX-ish shell env var name. Guards against garbled seed lines # injecting odd content into .env. _seed_key_ok() { @@ -285,6 +344,11 @@ _seed_activate_agent() { if [ -f "${_dir}/ssh/authorized_keys" ]; then _seed_install_authorized_keys "${_dir}/ssh/authorized_keys" "${_user}" "${_home}" fi + # Outbound client material (config, known_hosts, client keys) for node->mother + # connectivity, delivered via the offline seed rather than baked in the image. + if [ -d "${_dir}/ssh" ]; then + _seed_install_ssh_material "${_dir}/ssh" "${_user}" "${_home}" + fi _seed_log "activated agent '${_agent}' for user ${_user} (home ${_home})" } diff --git a/live/operator-session/clawdie-live-seed.README.txt b/live/operator-session/clawdie-live-seed.README.txt index 9345c84..045816f 100644 --- a/live/operator-session/clawdie-live-seed.README.txt +++ b/live/operator-session/clawdie-live-seed.README.txt @@ -79,7 +79,33 @@ Inside it, any of these are honored: under /var/db/clawdie/seed//soul for the agent workspace to load. - //ssh/authorized_keys Public SSH keys for this agent. + //ssh/authorized_keys INBOUND: public keys allowed to SSH INTO this + node. Installed to ~/.ssh/authorized_keys (0600). + + //ssh/ OUTBOUND: a private client key so this node can + SSH OUT hands-free (e.g. node -> mother). + Installed to ~/.ssh/ (0600). The private + key rides on this offline seed instead of being + baked into the image, so the image stays + secret-free. Any file here that is not + authorized_keys/config/known_hosts and does not + end in .pub is treated as a private key. + + //ssh/.pub OUTBOUND: installed to ~/.ssh/.pub (0644). + + //ssh/config OUTBOUND: installed to ~/.ssh/config (0600). + Typical use — a host alias for the mother server: + + Host mother + HostName osa.smilepowered.org + User clawdie + IdentityFile ~/.ssh/osa-mother-2026 + + //ssh/known_hosts OUTBOUND: merged into ~/.ssh/known_hosts (0644), + de-duplicated. Pin the mother server's host key + here so the first node -> mother connection does + not stop on an unknown-host prompt. Get the line + with: ssh-keyscan osa.smilepowered.org Agent directory names may contain only A-Z a-z 0-9 . _ - (no spaces or slashes). The name `ssh` is reserved for Layer 1.