From abdd9cd6c8d387a9d95ca1bfcbf473776202b41c Mon Sep 17 00:00:00 2001 From: dietrich ayala Date: Mon, 10 Aug 2026 23:44:05 +0200 Subject: [PATCH] give the box its own Tangled key, and sync config from the working tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps, both of which left work stranded. The box held no key reaching Tangled, only the Storage Box key, so pushing needed the operator's forwarded agent — which exists only while the operator is connected. An agent working unattended had no route for its work to leave the machine. laptop/tangled-key.sh generates a key on the laptop, escrows it as tangled-key, verifies the readback, and only then installs it at ~/.ssh/tangled_ed25519 mode 0600: the same order as the Storage Box key, and for the same reason, so a rebuild reproduces the key from the vault instead of needing its public half registered by hand again. Registering that public half is the one step no script here can do — there is no API — so the run ends by deferring it with the text to paste. A key that exists but was never registered fails exactly like a missing one, so verify.sh asserts the push path rather than the file. That assertion is no longer skippable, and it forces IdentityAgent=none: with an agent forwarded it would otherwise pass on the operator's identity while the box's own key was missing, proving the opposite of what it claims. Exactly one assertion may now SKIP. server/lib/45-ssh-config.sh names the key and matches tangled.sh as well as tangled.org, since remotes across the projects here are written both ways. The global agent configuration reached the box only through a full provisioning run, via `git archive HEAD`, so the box ran the last committed state and a dirty tree was refused outright. laptop/sync-config.sh ships the working tree instead. Its file list comes from `git ls-files --cached --others --exclude-standard` rather than the filesystem, which is load-bearing rather than tidy: ~/.claude is deny-by-default gitignored, so the payload is about 2 MB where the directory is over a gigabyte of session transcripts, and settings.local.json — ignored because it can hold credentials captured from an approved command line — stays out of it. The staging directory is replaced rather than overlaid, because neither `git archive | tar -x` nor an --files-from rsync removes anything, so a file deleted from the repository lived on the box forever. SYNC_OVERWRITE=1 is passed only by that script. deliver_rendered() defers on any difference, which is right for a provisioning run but would mean an explicit sync defers forever and never works twice (standing rule 18). The box's own state belongs in settings.local.json, which nothing there touches, so only the delivered artifact is replaced, and only after a backup. flit_remote_env() moves to lib/common.sh: two callers now run the same server-side script, and a second hand-written copy of that assignment list is the drift §13.2 keeps counting. --- bootstrap.sh | 51 +++++++------ laptop/sync-config.sh | 118 ++++++++++++++++++++++++++++++ laptop/tangled-key.sh | 128 +++++++++++++++++++++++++++++++++ lib/common.sh | 30 ++++++++ server/lib/22-claude-config.sh | 19 ++++- server/lib/45-ssh-config.sh | 32 +++++++-- verify.sh | 35 +++++---- 7 files changed, 366 insertions(+), 47 deletions(-) create mode 100755 laptop/sync-config.sh create mode 100755 laptop/tangled-key.sh diff --git a/bootstrap.sh b/bootstrap.sh index 11322e1..9a5f470 100755 --- a/bootstrap.sh +++ b/bootstrap.sh @@ -101,31 +101,9 @@ remote() { ssh "${SSH_OPTS[@]}" "$FLIT_USER@$HOST" "$@"; } FLIT_LAPTOP_HOME=$HOME export FLIT_LAPTOP_HOME -# flit_remote_env — the FLIT_NAME/FLIT_USER/FLIT_SERVER/FLIT_LAPTOP_HOME -# assignment list every ssh call that runs on a stripped environment (`sudo` -# strips it; so does a non-login remote command) must carry explicitly. ONE -# function rather than five hand-written copies, so a variable added here -# reaches every call site instead of whichever ones happened to get edited — -# the failure this exists to prevent (§13.2). Callers append whatever else -# that particular remote script needs (NO_EXTERNAL_STATE, STORAGEBOX_USER, -# ...) after this. -flit_remote_env() { - # FLIT_GIT_NAME/FLIT_GIT_EMAIL are identifying (CLAUDE.md), so they live - # only in the gitignored local.conf, which phase_2_deliver() excludes from - # the rsync push by design — forwarding them here is the only route by - # which a value set there reaches the server at all. - # - # %q, not '%s'. A '%s' format is safe only while no value can contain a - # single quote, and FLIT_GIT_NAME breaks that assumption: an apostrophe in a - # personal name would close the quoting and hand the remainder of the - # assignment list to the far shell as code. %q quotes for a shell to read - # back, exists in bash 3.2 as well as 5, and renders an empty value as '' - # rather than as nothing — which is what stops an unset FLIT_GIT_EMAIL from - # swallowing the word after it. - printf "FLIT_NAME=%q FLIT_USER=%q FLIT_SERVER=%q FLIT_LAPTOP_HOME=%q FLIT_IN_DRILL=%q FLIT_GIT_NAME=%q FLIT_GIT_EMAIL=%q" \ - "$FLIT_NAME" "$FLIT_USER" "$FLIT_SERVER" "$FLIT_LAPTOP_HOME" "${FLIT_IN_DRILL:-}" \ - "${FLIT_GIT_NAME:-}" "${FLIT_GIT_EMAIL:-}" -} +# flit_remote_env() lives in lib/common.sh: laptop/sync-config.sh runs the same +# server-side script this file does, and a second hand-written copy of the +# assignment list is exactly the drift §13.2 keeps counting. # --------------------------------------------------------------- preflight @@ -555,7 +533,28 @@ phase_6_terminal() { fi } -phase_7_sshconfig() { log "phase 7: server ssh config"; run_server_lib 45-ssh-config.sh; } +# The key is installed BEFORE the ssh config that names it, so a run never +# leaves a config pointing at a path with nothing at it — ssh treats a missing +# IdentityFile as no identity at all and falls through to "Permission denied +# (publickey)", which reads like an unregistered key rather than a missing one. +# +# Skipped under --no-external-state: registering a key against the Tangled +# account is state shared with production, and a drill must not touch it. The +# drill box therefore has no Tangled key, which is why verify.sh's assertion is +# one the drills are expected to fail on rather than one they assert. +phase_7_sshconfig() { + log "phase 7: server ssh config" + if [[ $NO_EXTERNAL_STATE -eq 1 ]]; then + info "skipping the Tangled key: --no-external-state" + else + # EXIT_DEFERRED is success at the script's real job (§5.8), not failure: + # the key is installed and only its registration with the Tangled account + # is left to the operator, which no script here can do. + laptop/tangled-key.sh "$HOST" \ + || { [[ $? -eq $EXIT_DEFERRED ]] || die "tangled-key.sh failed"; } + fi + run_server_lib 45-ssh-config.sh +} phase_8_alerting() { log "phase 8: alerting (laptop half)" diff --git a/laptop/sync-config.sh b/laptop/sync-config.sh new file mode 100755 index 0000000..b529578 --- /dev/null +++ b/laptop/sync-config.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# laptop/sync-config.sh — push the WORKING TREE of the global agent +# configuration to the box, without a full provisioning run. +# +# laptop/sync-config.sh [host] +# +# WHY THIS EXISTS: bootstrap.sh phase_2_deliver() moves ~/.claude and +# ~/misc/chook with `git archive HEAD`, so the box runs whatever was committed +# at the last provisioning run and drifts behind every edit since — and +# check_clean() refuses a dirty tree outright, because git archive ships the +# last commit and silently omits the rest. That guard is correct for an archive +# and pointless here: this script ships the working tree, so an uncommitted +# tweak is the case it exists to serve rather than the case it must refuse. +# +# WHY THIS IS IN laptop/: it is a push, and the box never fetches its own +# configuration (§5.1). The direction IS the design. + +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 +# shellcheck source=lib/common.sh +source lib/common.sh +load_laptop_conf + +HOST=${1:-$FLIT_SERVER} + +# Set here for the same reason bootstrap.sh sets it: flit_remote_env() forwards +# it, and no server-side default could guess this laptop's home. +FLIT_LAPTOP_HOME=$HOME +export FLIT_LAPTOP_HOME + +SSH_OPTS=(-o BatchMode=yes -o StrictHostKeyChecking=accept-new) +remote() { ssh "${SSH_OPTS[@]}" "$FLIT_USER@$HOST" "$@"; } + +need_cmd rsync +need_cmd git + +# sync_tree