diff --git a/.gitignore b/.gitignore index 5ed2da0c3..6e9565557 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,5 @@ config/backlog-backend config/backend config/x-mode.env config/cmux-socket-password +config/cmux-env-secrets config/wedge-alarm diff --git a/AGENTS.md b/AGENTS.md index 90f9e796e..64a37bf1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -159,7 +159,7 @@ Do not add model-specific versions of that policy. `secondmate-provisioning` owns secondmate harness pins and config inheritance, while `harness-adapters` owns the harness consequences. Dispatch only on a backend that `fm-spawn` validates as spawn-capable. -A missing dependency, authentication failure, unsupported backend, or version refusal is a blocker; never silently retry on another backend. +A missing dependency, authentication failure, unsupported backend, version refusal, or failed provider-credential fetch is a blocker; never silently retry on another backend. ## 5. Recovery diff --git a/bin/backends/cmux.sh b/bin/backends/cmux.sh index 69dc0b53b..0f9bf1c7a 100644 --- a/bin/backends/cmux.sh +++ b/bin/backends/cmux.sh @@ -341,6 +341,93 @@ fm_backend_cmux_surface_id_for_workspace() { # | jq -r '.panes[0] // {} | .selected_surface_id // (.surface_ids[0] // empty)' 2>/dev/null } +# fm_backend_cmux_secret_specs_for_harness: zero or more "ENV_VAR secret-name +# [region]" lines naming the provider credentials that must be explicitly +# injected into a cmux workspace for , read fresh from the local, +# gitignored config/cmux-env-secrets under the effective config dir (the same +# read-fresh convention as fm_backend_cmux_password above). Each config line +# is " []"; blank lines and +# '#'-prefixed lines are ignored (docs/cmux-backend.md "Provider credential +# passthrough (--env)"). Needed only for cmux: unlike tmux/herdr/zellij +# (which fork a task inside the same host shell and so inherit firstmate's +# own process environment), a cmux terminal surface's environment is heavily +# scrubbed at spawn time - ambient provider API keys never reach it. An +# absent file, or a harness with no matching line, means no injection at all; +# a harness-matching line missing its secret name is skipped with a stderr +# warning so a mistyped pairing surfaces at spawn instead of silently +# reverting to no injection. +fm_backend_cmux_secret_specs_for_harness() { # + local harness=$1 config_dir="${FM_CONFIG_OVERRIDE:-$FM_HOME/config}" f h env_var secret_name region _rest + f="$config_dir/cmux-env-secrets" + [ -f "$f" ] || return 0 + while read -r h env_var secret_name region _rest || [ -n "$h" ]; do + case "$h" in ''|'#'*) continue ;; esac + [ "$h" = "$harness" ] || continue + if [ -z "$secret_name" ]; then + echo "warning: cmux env passthrough: ignoring malformed line in $f (missing secret name): $h${env_var:+ $env_var}" >&2 + continue + fi + if [ -n "$region" ]; then + printf '%s %s %s\n' "$env_var" "$secret_name" "$region" + else + printf '%s %s\n' "$env_var" "$secret_name" + fi + done < "$f" +} + +# fm_backend_cmux_fetch_secret: print 's current value from AWS +# Secrets Manager on stdout (in [region] when given, otherwise whatever +# region is ambient in AWS config), using whatever AWS credentials/profile +# are already ambient in this process's environment - never a hardcoded +# profile. Fails loudly rather than continuing without the key: a missing +# 'aws' CLI, a failed call (whose AWS stderr is included in the error - AWS +# error text never contains the secret value), or an empty secret are all +# refused. +fm_backend_cmux_fetch_secret() { # [region] + local secret_name=$1 region=${2:-} where value err_file aws_err status=0 + where="AWS Secrets Manager${region:+ (region $region)}" + command -v aws >/dev/null 2>&1 || { echo "error: cmux env passthrough needs secret '$secret_name' but the 'aws' CLI is not installed" >&2; return 1; } + err_file=$(mktemp "${TMPDIR:-/tmp}/fm-cmux-aws-err.XXXXXX") || err_file=/dev/null + if [ -n "$region" ]; then + value=$(aws secretsmanager get-secret-value --secret-id "$secret_name" --region "$region" --query SecretString --output text 2>"$err_file") || status=$? + else + value=$(aws secretsmanager get-secret-value --secret-id "$secret_name" --query SecretString --output text 2>"$err_file") || status=$? + fi + aws_err= + if [ "$err_file" != /dev/null ]; then + aws_err=$(cat "$err_file" 2>/dev/null) + rm -f "$err_file" + fi + if [ "$status" -ne 0 ]; then + echo "error: failed to fetch secret '$secret_name' from $where - ${aws_err:-check that AWS credentials/profile are configured}" >&2 + return 1 + fi + if [ -z "$value" ] || [ "$value" = None ]; then + echo "error: secret '$secret_name' from $where returned an empty value" >&2 + return 1 + fi + printf '%s' "$value" +} + +# fm_backend_cmux_env_args_for_harness: resolve 's secret specs (if +# any, per fm_backend_cmux_secret_specs_for_harness) and set +# FM_BACKEND_CMUX_ENV_ARGS to the "--env KEY=VALUE" pairs to pass to +# `new-workspace`. A fetched value only ever lands in this array - never +# printed, logged, or written to a file - mirroring how +# fm_backend_cmux_password/CMUX_SOCKET_PASSWORD above handles the socket +# password. Empty array (success) when the harness needs no injected secret. +fm_backend_cmux_env_args_for_harness() { # + FM_BACKEND_CMUX_ENV_ARGS=() + local harness=$1 env_var secret_name region value + [ -n "$harness" ] || return 0 + while read -r env_var secret_name region; do + [ -n "$secret_name" ] || continue + value=$(fm_backend_cmux_fetch_secret "$secret_name" "$region") || return 1 + FM_BACKEND_CMUX_ENV_ARGS+=(--env "$env_var=$value") + done < <(fm_backend_cmux_secret_specs_for_harness "$harness") + return 0 +} + # fm_backend_cmux_create_task: create the task's workspace (one surface), # refusing an existing live