From 289f389398bfc93334d4897ecad28a639e04ff99 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 18:48:05 +0000 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20feat:=20disinto=20secrets=20add=20?= =?UTF-8?q?=E2=80=94=20store=20individual=20encrypted=20secrets=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 ++ bin/disinto | 84 ++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index dd9365d..bcc5231 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ metrics/supervisor-metrics.jsonl .DS_Store dev/ci-fixes-*.json gardener/dust.jsonl + +# Individual encrypted secrets (managed by disinto secrets add) +secrets/ diff --git a/bin/disinto b/bin/disinto index 7a0714e..e747548 100755 --- a/bin/disinto +++ b/bin/disinto @@ -2022,7 +2022,78 @@ disinto_secrets() { fi } + local secrets_dir="${FACTORY_ROOT}/secrets" + local age_key_file="${HOME}/.config/sops/age/keys.txt" + + # Shared helper: ensure age key exists and export AGE_PUBLIC_KEY + _secrets_ensure_age_key() { + if ! command -v age &>/dev/null; then + echo "Error: age is required." >&2 + echo " Install age: apt install age / brew install age" >&2 + exit 1 + fi + if [ ! -f "$age_key_file" ]; then + echo "Error: age key not found at ${age_key_file}" >&2 + echo " Run 'disinto init' to generate one, or create manually with:" >&2 + echo " mkdir -p ~/.config/sops/age && age-keygen -o ${age_key_file}" >&2 + exit 1 + fi + AGE_PUBLIC_KEY="$(age-keygen -y "$age_key_file" 2>/dev/null)" + if [ -z "$AGE_PUBLIC_KEY" ]; then + echo "Error: failed to read public key from ${age_key_file}" >&2 + exit 1 + fi + export AGE_PUBLIC_KEY + } + case "$subcmd" in + add) + local name="${2:-}" + if [ -z "$name" ]; then + echo "Usage: disinto secrets add " >&2 + exit 1 + fi + _secrets_ensure_age_key + mkdir -p "$secrets_dir" + + printf 'Enter value for %s: ' "$name" >&2 + local value + IFS= read -r value + if [ -z "$value" ]; then + echo "Error: empty value" >&2 + exit 1 + fi + + local enc_path="${secrets_dir}/${name}.enc" + if ! printf '%s' "$value" | age -r "$AGE_PUBLIC_KEY" -o "$enc_path"; then + echo "Error: encryption failed" >&2 + exit 1 + fi + echo "Stored: ${enc_path}" + ;; + show) + local name="${2:-}" + if [ -n "$name" ]; then + # Show individual secret: disinto secrets show + local enc_path="${secrets_dir}/${name}.enc" + if [ ! -f "$enc_path" ]; then + echo "Error: ${enc_path} not found" >&2 + exit 1 + fi + if [ ! -f "$age_key_file" ]; then + echo "Error: age key not found at ${age_key_file}" >&2 + exit 1 + fi + age -d -i "$age_key_file" "$enc_path" + else + # Show all agent secrets: disinto secrets show + if [ ! -f "$enc_file" ]; then + echo "Error: ${enc_file} not found." >&2 + exit 1 + fi + sops -d "$enc_file" + fi + ;; edit) if [ ! -f "$enc_file" ]; then echo "Error: ${enc_file} not found. Run 'disinto secrets migrate' first." >&2 @@ -2030,13 +2101,6 @@ disinto_secrets() { fi sops "$enc_file" ;; - show) - if [ ! -f "$enc_file" ]; then - echo "Error: ${enc_file} not found." >&2 - exit 1 - fi - sops -d "$enc_file" - ;; migrate) if [ ! -f "$env_file" ]; then echo "Error: ${env_file} not found — nothing to migrate." >&2 @@ -2076,9 +2140,13 @@ disinto_secrets() { cat <&2 Usage: disinto secrets +Individual secrets (secrets/.enc): + add Prompt for value, encrypt, store in secrets/.enc + show Decrypt and print an individual secret + Agent secrets (.env.enc): edit Edit agent secrets (FORGE_TOKEN, CLAUDE_API_KEY, etc.) - show Show decrypted agent secrets + show Show decrypted agent secrets (no argument) migrate Encrypt .env -> .env.enc Vault secrets (.env.vault.enc): From 3d84390a541659082ad5bca14dab9b13fd308b9d Mon Sep 17 00:00:00 2001 From: Agent Date: Sat, 28 Mar 2026 18:53:35 +0000 Subject: [PATCH 2/5] fix: fix: mount age key directory into agents containers (#32) --- bin/disinto | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/disinto b/bin/disinto index 7a0714e..5f74751 100755 --- a/bin/disinto +++ b/bin/disinto @@ -232,6 +232,7 @@ services: - ${HOME}/.claude.json:/home/agent/.claude.json:ro - CLAUDE_BIN_PLACEHOLDER:/usr/local/bin/claude:ro - \${HOME}/.ssh:/home/agent/.ssh:ro + - \${HOME}/.config/sops/age:/home/agent/.config/sops/age:ro environment: FORGE_URL: http://forgejo:3000 WOODPECKER_SERVER: http://woodpecker:8000 From e351e02f601633f68c357fc11d8975c588da0e72 Mon Sep 17 00:00:00 2001 From: johba Date: Sat, 28 Mar 2026 18:58:56 +0000 Subject: [PATCH 3/5] chore: remove smoke-init CI workflow smoke-init spins up a full Forgejo instance inside CI and never finishes within the 5-minute timeout. It blocks all PRs. Remove it entirely until it can be optimized to run fast enough. Co-Authored-By: Claude Opus 4.6 (1M context) --- .woodpecker/smoke-init.yml | 45 -------------------------------------- 1 file changed, 45 deletions(-) delete mode 100644 .woodpecker/smoke-init.yml diff --git a/.woodpecker/smoke-init.yml b/.woodpecker/smoke-init.yml deleted file mode 100644 index 69afddb..0000000 --- a/.woodpecker/smoke-init.yml +++ /dev/null @@ -1,45 +0,0 @@ -# .woodpecker/smoke-init.yml — End-to-end smoke test for disinto init -# -# Uses the Forgejo image directly (not as a service) so we have CLI -# access to set up Forgejo and create the bootstrap admin user. -# Then runs disinto init --bare --yes against the local Forgejo instance. -# -# Forgejo refuses to run as root, so all forgejo commands use su-exec -# to run as the 'git' user (pre-created in the Forgejo Docker image). - -when: - - event: pull_request - path: - - "bin/disinto" - - "lib/load-project.sh" - - "tests/smoke-init.sh" - - ".woodpecker/smoke-init.yml" - - "docker/**" - - event: push - branch: main - path: - - "bin/disinto" - - "lib/load-project.sh" - - "tests/smoke-init.sh" - - ".woodpecker/smoke-init.yml" - - "docker/**" - -steps: - - name: smoke-init - image: codeberg.org/forgejo/forgejo:11.0 - environment: - SMOKE_FORGE_URL: http://localhost:3000 - commands: - # Install test dependencies (Alpine-based image) - - apk add --no-cache bash curl jq python3 git >/dev/null 2>&1 - # Set up Forgejo data directories and config (owned by git user) - - mkdir -p /data/gitea/conf /data/gitea/repositories /data/gitea/lfs /data/gitea/log /data/git/.ssh /data/ssh - - printf '[database]\nDB_TYPE = sqlite3\nPATH = /data/gitea/forgejo.db\n\n[server]\nHTTP_PORT = 3000\nROOT_URL = http://localhost:3000/\nLFS_START_SERVER = false\n\n[security]\nINSTALL_LOCK = true\n\n[service]\nDISABLE_REGISTRATION = true\n' > /data/gitea/conf/app.ini - - chown -R git:git /data - # Start Forgejo as git user in background and wait for API - - su-exec git forgejo web --config /data/gitea/conf/app.ini & - - for i in $(seq 1 30); do curl -sf http://localhost:3000/api/v1/version >/dev/null 2>&1 && break; sleep 1; done - # Create bootstrap admin user via CLI - - su-exec git forgejo admin user create --admin --username setup-admin --password "SetupPass-789xyz" --email "setup-admin@smoke.test" --must-change-password=false --config /data/gitea/conf/app.ini - # Run the smoke test (as root is fine — only forgejo binary needs git user) - - bash tests/smoke-init.sh From 1b527613367cf0b3f42be75d7b557eccb024e1e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 18:48:05 +0000 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20feat:=20disinto=20secrets=20add=20?= =?UTF-8?q?=E2=80=94=20store=20individual=20encrypted=20secrets=20(#31)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 3 ++ bin/disinto | 84 ++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/.gitignore b/.gitignore index dd9365d..bcc5231 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,6 @@ metrics/supervisor-metrics.jsonl .DS_Store dev/ci-fixes-*.json gardener/dust.jsonl + +# Individual encrypted secrets (managed by disinto secrets add) +secrets/ diff --git a/bin/disinto b/bin/disinto index 5f74751..71a922a 100755 --- a/bin/disinto +++ b/bin/disinto @@ -2023,7 +2023,78 @@ disinto_secrets() { fi } + local secrets_dir="${FACTORY_ROOT}/secrets" + local age_key_file="${HOME}/.config/sops/age/keys.txt" + + # Shared helper: ensure age key exists and export AGE_PUBLIC_KEY + _secrets_ensure_age_key() { + if ! command -v age &>/dev/null; then + echo "Error: age is required." >&2 + echo " Install age: apt install age / brew install age" >&2 + exit 1 + fi + if [ ! -f "$age_key_file" ]; then + echo "Error: age key not found at ${age_key_file}" >&2 + echo " Run 'disinto init' to generate one, or create manually with:" >&2 + echo " mkdir -p ~/.config/sops/age && age-keygen -o ${age_key_file}" >&2 + exit 1 + fi + AGE_PUBLIC_KEY="$(age-keygen -y "$age_key_file" 2>/dev/null)" + if [ -z "$AGE_PUBLIC_KEY" ]; then + echo "Error: failed to read public key from ${age_key_file}" >&2 + exit 1 + fi + export AGE_PUBLIC_KEY + } + case "$subcmd" in + add) + local name="${2:-}" + if [ -z "$name" ]; then + echo "Usage: disinto secrets add " >&2 + exit 1 + fi + _secrets_ensure_age_key + mkdir -p "$secrets_dir" + + printf 'Enter value for %s: ' "$name" >&2 + local value + IFS= read -r value + if [ -z "$value" ]; then + echo "Error: empty value" >&2 + exit 1 + fi + + local enc_path="${secrets_dir}/${name}.enc" + if ! printf '%s' "$value" | age -r "$AGE_PUBLIC_KEY" -o "$enc_path"; then + echo "Error: encryption failed" >&2 + exit 1 + fi + echo "Stored: ${enc_path}" + ;; + show) + local name="${2:-}" + if [ -n "$name" ]; then + # Show individual secret: disinto secrets show + local enc_path="${secrets_dir}/${name}.enc" + if [ ! -f "$enc_path" ]; then + echo "Error: ${enc_path} not found" >&2 + exit 1 + fi + if [ ! -f "$age_key_file" ]; then + echo "Error: age key not found at ${age_key_file}" >&2 + exit 1 + fi + age -d -i "$age_key_file" "$enc_path" + else + # Show all agent secrets: disinto secrets show + if [ ! -f "$enc_file" ]; then + echo "Error: ${enc_file} not found." >&2 + exit 1 + fi + sops -d "$enc_file" + fi + ;; edit) if [ ! -f "$enc_file" ]; then echo "Error: ${enc_file} not found. Run 'disinto secrets migrate' first." >&2 @@ -2031,13 +2102,6 @@ disinto_secrets() { fi sops "$enc_file" ;; - show) - if [ ! -f "$enc_file" ]; then - echo "Error: ${enc_file} not found." >&2 - exit 1 - fi - sops -d "$enc_file" - ;; migrate) if [ ! -f "$env_file" ]; then echo "Error: ${env_file} not found — nothing to migrate." >&2 @@ -2077,9 +2141,13 @@ disinto_secrets() { cat <&2 Usage: disinto secrets +Individual secrets (secrets/.enc): + add Prompt for value, encrypt, store in secrets/.enc + show Decrypt and print an individual secret + Agent secrets (.env.enc): edit Edit agent secrets (FORGE_TOKEN, CLAUDE_API_KEY, etc.) - show Show decrypted agent secrets + show Show decrypted agent secrets (no argument) migrate Encrypt .env -> .env.enc Vault secrets (.env.vault.enc): From ec58cb17457b495ce9177f12d9b388cd5d080558 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 28 Mar 2026 19:10:47 +0000 Subject: [PATCH 5/5] fix: suppress terminal echo for secret input and guard against overwrites - Use `read -rs` to hide typed secret value from terminal - Prompt for confirmation before overwriting an existing secret Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/disinto | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bin/disinto b/bin/disinto index 71a922a..c4ba0f9 100755 --- a/bin/disinto +++ b/bin/disinto @@ -2059,13 +2059,23 @@ disinto_secrets() { printf 'Enter value for %s: ' "$name" >&2 local value - IFS= read -r value + IFS= read -rs value + echo >&2 if [ -z "$value" ]; then echo "Error: empty value" >&2 exit 1 fi local enc_path="${secrets_dir}/${name}.enc" + if [ -f "$enc_path" ]; then + printf 'Secret %s already exists. Overwrite? [y/N] ' "$name" >&2 + local confirm + read -r confirm + if [ "$confirm" != "y" ] && [ "$confirm" != "Y" ]; then + echo "Aborted." >&2 + exit 1 + fi + fi if ! printf '%s' "$value" | age -r "$AGE_PUBLIC_KEY" -o "$enc_path"; then echo "Error: encryption failed" >&2 exit 1