From 45b2ac020e2722ed3d20d63c2300e4571c0f61a1 Mon Sep 17 00:00:00 2001 From: knowttl Date: Tue, 11 Aug 2026 20:53:04 -0700 Subject: [PATCH] feat(spawn): select per-project GitHub account for worker gh operations Worker gh calls (PR creation, gh-axi, the no-mistakes pipeline) authenticate with the token selected by GH_CONFIG_DIR, which defaults to ~/.config/gh. That default account is not SSO-authorized for every org, so gh pr create failed with a SAML 403 on projects under an org that enforces SSO unless GH_CONFIG_DIR was set by hand. Resolve the account from the project's origin remote and export GH_CONFIG_DIR at the matching per-account config in the primary firstmate home's data/gh-config into each ship/scout worker's pane, alongside the existing GOTMPDIR export. A secondmate-home worker reaches the same primary-home configs via the local-route parent record, so main-home and secondmate-home workers use the same authorized store. An origin that does not map to a known account, or a missing config, leaves the environment unchanged rather than guessing an account. Git transport and credential helpers are unchanged; this only selects the gh CLI config directory. --- bin/fm-gh-account-lib.sh | 116 +++++++++++++++++ bin/fm-spawn.sh | 15 +++ tests/fm-gh-account.test.sh | 240 ++++++++++++++++++++++++++++++++++++ 3 files changed, 371 insertions(+) create mode 100644 bin/fm-gh-account-lib.sh create mode 100755 tests/fm-gh-account.test.sh diff --git a/bin/fm-gh-account-lib.sh b/bin/fm-gh-account-lib.sh new file mode 100644 index 0000000000..f1df9d2ea3 --- /dev/null +++ b/bin/fm-gh-account-lib.sh @@ -0,0 +1,116 @@ +# shellcheck shell=bash +# Per-project GitHub account selection for spawned worker gh operations. +# Usage: . bin/fm-gh-account-lib.sh +# +# The captain runs two GitHub accounts isolated per project. Git transport is +# already routed durably (SSH host aliases + insteadOf + includeIf), but the gh +# CLI selects its token from GH_CONFIG_DIR (default ~/.config/gh), and that +# default account is not SSO-authorized for every org. When firstmate launches a +# ship/scout worker, fm-spawn.sh resolves the project's account from its origin +# remote and exports GH_CONFIG_DIR into the worker's pane so every gh call the +# worker (and the no-mistakes pipeline it drives) makes uses the right account. +# +# The gh account configs live only in the PRIMARY firstmate home's data/gh-config +# (one subdir per account, e.g. data/gh-config/pwxgh). A crewmate spawned from a +# secondmate home reaches the same primary-home configs via the local-route +# parent record (fm-secondmate-parent-lib.sh), so both a main-home and a +# secondmate-home crewmate authenticate against the same authorized store. +# +# When an origin does not map to a known account (an unrelated repo), or the +# resolved config dir is absent, these functions produce nothing and fm-spawn +# leaves the environment unchanged - never guessing an account. + +# shellcheck source=bin/fm-secondmate-parent-lib.sh +. "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/fm-secondmate-parent-lib.sh" + +# The account map. Each row: ||. +# is also the data/gh-config subdir name. Add a row to extend; keep it +# the single home for the mapping rather than scattering org names elsewhere. +_fm_gh_account_table() { + cat <<'TABLE' +knowttl|github.com-knowttl|knowttl +pwxgh|github.com-pwxgh|powerex-development +TABLE +} + +# Map an origin URL to an account name. Prints the account and returns 0 on a +# match; prints nothing and returns 1 otherwise. Handles the https, ssh://, +# scp-like git@host:path, and bare ssh-alias host:path origin forms. +fm_gh_account_for_origin() { + local origin=$1 rest host path ns + [ -n "$origin" ] || return 1 + case "$origin" in + *://*) + # scheme://[user@]host/path + rest=${origin#*://} + rest=${rest#*@} + host=${rest%%/*} + path=${rest#*/} + ;; + *@*:*) + # user@host:path (scp-like) + rest=${origin#*@} + host=${rest%%:*} + path=${rest#*:} + ;; + *:*) + # host:path (scp-like, no user - e.g. a bare ssh-alias origin) + host=${origin%%:*} + path=${origin#*:} + ;; + *) + return 1 + ;; + esac + ns=${path%%/*} + + local account alias_host namespace + while IFS='|' read -r account alias_host namespace; do + [ -n "$account" ] || continue + if [ "$host" = "$alias_host" ]; then + printf '%s\n' "$account" + return 0 + fi + if [ "$host" = github.com ] && [ "$ns" = "$namespace" ]; then + printf '%s\n' "$account" + return 0 + fi + done < <(_fm_gh_account_table) + return 1 +} + +# Resolve the primary firstmate home's gh-config base for the given home. For a +# main home this is /data/gh-config; for a secondmate home it is the +# local-route parent home's data/gh-config, so the authorized configs are reached +# from either. Prints the base dir and returns 0 only when it exists; returns 1 +# for a remote or unresolvable parent, or a missing base. +fm_gh_config_base_for_home() { + local home=$1 base + [ -n "$home" ] || return 1 + if [ -f "$home/.fm-secondmate-home" ]; then + fm_secondmate_parent_record_parse "$home/.fm-secondmate-parent" || return 1 + [ "$FM_SECONDMATE_PARENT_ROUTE" = local ] || return 1 + [ -n "$FM_SECONDMATE_PARENT_HOME" ] || return 1 + base="$FM_SECONDMATE_PARENT_HOME/data/gh-config" + else + base="$home/data/gh-config" + fi + [ -d "$base" ] || return 1 + printf '%s\n' "$base" +} + +# Resolve the GH_CONFIG_DIR a worker launched for under should +# use. Reads 's origin, maps it to an account, and composes the account +# subdir under the primary home's gh-config base. Prints the absolute dir and +# returns 0 only when the origin maps to a known account and that account's +# config dir exists; otherwise prints nothing and returns 1. +fm_gh_config_dir_for_spawn() { + local home=$1 git_dir=$2 origin account base dir + [ -n "$git_dir" ] || return 1 + origin=$(git -C "$git_dir" remote get-url origin 2>/dev/null) || return 1 + account=$(fm_gh_account_for_origin "$origin") || return 1 + base=$(fm_gh_config_base_for_home "$home") || return 1 + dir="$base/$account" + [ -d "$dir" ] || return 1 + printf '%s\n' "$dir" +} diff --git a/bin/fm-spawn.sh b/bin/fm-spawn.sh index eb729df1a0..cca3829e5d 100755 --- a/bin/fm-spawn.sh +++ b/bin/fm-spawn.sh @@ -253,6 +253,8 @@ SUB_HOME_MARKER=".fm-secondmate-home" . "$SCRIPT_DIR/fm-trace-context-lib.sh" # shellcheck source=bin/fm-remote-readiness-lib.sh . "$SCRIPT_DIR/fm-remote-readiness-lib.sh" +# shellcheck source=bin/fm-gh-account-lib.sh +. "$SCRIPT_DIR/fm-gh-account-lib.sh" # Fail closed before any fleet mutation: a no-mistakes gate agent must never spawn # a direct report (see bin/fm-gate-refuse-lib.sh). fm_refuse_if_gate_agent @@ -2716,6 +2718,19 @@ spawn_record_traceparent() { # process (go build, go test, ...) inherit it. Sent before the launch command so # the env is set when the agent starts; the brief sleep lets the export land. spawn_send_text_line "$T" "export GOTMPDIR=$TASK_TMP/gotmp" +# Select the per-project GitHub account for the worker's gh calls (and the +# no-mistakes pipeline it drives) by exporting GH_CONFIG_DIR at the correct +# account config in the primary home's data/gh-config. Sent through the same +# channel as GOTMPDIR, before launch, so the env is set when the agent starts. +# Only for project workers (ship/scout); a secondmate spawn is not project work. +# Resolves from the project's origin remote and stays silent when the origin does +# not map to a known account or the config is absent, leaving the env unchanged. +if [ "$KIND" != secondmate ]; then + GH_CONFIG_DIR_EXPORT=$(fm_gh_config_dir_for_spawn "$FM_HOME" "$PROJ_ABS" 2>/dev/null) || GH_CONFIG_DIR_EXPORT= + if [ -n "$GH_CONFIG_DIR_EXPORT" ]; then + spawn_send_text_line "$T" "export GH_CONFIG_DIR=$GH_CONFIG_DIR_EXPORT" + fi +fi # Send through the exact channel that already ships GOTMPDIR, so every backend # and harness - ship, scout, and secondmate - gets it before launch. Skipped # entirely when trace context is off. diff --git a/tests/fm-gh-account.test.sh b/tests/fm-gh-account.test.sh new file mode 100755 index 0000000000..23f4a6ac52 --- /dev/null +++ b/tests/fm-gh-account.test.sh @@ -0,0 +1,240 @@ +#!/usr/bin/env bash +# Behavior tests for per-project GitHub account selection (fm-gh-account-lib.sh). +# +# fm-spawn.sh sources this library and, for a ship/scout worker, exports +# GH_CONFIG_DIR at the account config resolved from the project's origin remote, +# so every gh call the worker makes authenticates against the right account. +# +# These tests drive the library's public functions directly - the same functions +# fm-spawn calls - against real origin URLs, real temporary git repos, and real +# fake home directories. They never assert the library's source bytes. +set -u + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# shellcheck source=bin/fm-gh-account-lib.sh +. "$ROOT/bin/fm-gh-account-lib.sh" + +fail() { + printf 'not ok - %s\n' "$1" >&2 + exit 1 +} + +pass() { + printf 'ok - %s\n' "$1" +} + +TMP_ROOT= +cleanup() { + if [ -n "${TMP_ROOT:-}" ]; then + rm -rf "$TMP_ROOT" + fi +} +trap cleanup EXIT +TMP_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/fm-gh-account-tests.XXXXXX") + +# --- fm_gh_account_for_origin: the pure origin -> account mapping --- + +assert_account() { # + local origin=$1 want=$2 got + got=$(fm_gh_account_for_origin "$origin") \ + || fail "origin '$origin' did not map (expected '$want')" + [ "$got" = "$want" ] \ + || fail "origin '$origin' mapped to '$got', expected '$want'" +} + +assert_no_account() { # + local origin=$1 got + if got=$(fm_gh_account_for_origin "$origin"); then + fail "origin '$origin' mapped to '$got' but should map to no account" + fi + [ -z "$got" ] || fail "origin '$origin' printed '$got' on no-account result" +} + +test_account_mapping() { + # knowttl: ssh-alias host and github.com namespace, across origin forms. + assert_account 'git@github.com-knowttl:knowttl/awx-axi.git' knowttl + assert_account 'github.com-knowttl:knowttl/awx-axi.git' knowttl + assert_account 'https://github.com/knowttl/awx-axi.git' knowttl + assert_account 'git@github.com:knowttl/awx-axi.git' knowttl + assert_account 'ssh://git@github.com/knowttl/awx-axi.git' knowttl + + # pwxgh: ssh-alias host and powerex-development namespace, across origin forms. + assert_account 'git@github.com-pwxgh:powerex-development/ops.cs.azure-epac.git' pwxgh + assert_account 'github.com-pwxgh:powerex-development/ops.cs.azure-epac.git' pwxgh + assert_account 'https://github.com/powerex-development/ops.cs.azure-epac.git' pwxgh + assert_account 'git@github.com:powerex-development/ops.cs.azure-epac.git' pwxgh + + # Unrelated origins map to no account (never guess). + assert_no_account 'git@github.com:someorg/repo.git' + assert_no_account 'https://github.com/other-user/thing.git' + assert_no_account 'git@gitlab.com:knowttl/repo.git' + assert_no_account 'git@github.com-unknown:foo/repo.git' + assert_no_account '' + + pass "fm_gh_account_for_origin maps both account forms and rejects unrelated origins" +} + +# --- fm_gh_config_base_for_home: primary-home gh-config base resolution --- + +test_config_base_main_home() { + local home="$TMP_ROOT/main-home" + mkdir -p "$home/data/gh-config" + local got + got=$(fm_gh_config_base_for_home "$home") \ + || fail "main home base did not resolve" + [ "$got" = "$home/data/gh-config" ] \ + || fail "main home base resolved to '$got'" + pass "fm_gh_config_base_for_home resolves a main home's own data/gh-config" +} + +test_config_base_secondmate_local_parent() { + # A secondmate home's own gh-config is absent; it must resolve the local-route + # parent (primary) home's data/gh-config instead. + local primary="$TMP_ROOT/primary" + local sm="$TMP_ROOT/sm-local" + mkdir -p "$primary/data/gh-config" + mkdir -p "$sm" + printf 'seed-id\n' > "$sm/.fm-secondmate-home" + cat > "$sm/.fm-secondmate-parent" < "$sm/.fm-secondmate-home" + cat > "$sm/.fm-secondmate-parent" </dev/null; then + fail "remote-route secondmate home resolved a base but should not" + fi + pass "fm_gh_config_base_for_home refuses a remote-route secondmate home" +} + +test_config_base_missing_dir() { + # A home whose data/gh-config does not exist resolves nothing. + local home="$TMP_ROOT/no-config-home" + mkdir -p "$home/data" + if fm_gh_config_base_for_home "$home" >/dev/null; then + fail "home without data/gh-config resolved a base but should not" + fi + pass "fm_gh_config_base_for_home refuses a home with no data/gh-config" +} + +# --- fm_gh_config_dir_for_spawn: end-to-end from a repo's origin to GH_CONFIG_DIR --- + +make_repo_with_origin() { # -> prints repo path + local name=$1 origin=$2 repo + repo="$TMP_ROOT/$name" + mkdir -p "$repo" + git -C "$repo" init -q + git -C "$repo" remote add origin "$origin" + printf '%s' "$repo" +} + +test_spawn_dir_mapped_project() { + # A main home with both account configs present. + local home="$TMP_ROOT/spawn-main" + mkdir -p "$home/data/gh-config/knowttl" "$home/data/gh-config/pwxgh" + + local repo got + # knowttl via ssh-alias origin. + repo=$(make_repo_with_origin knowttl-repo 'git@github.com-knowttl:knowttl/awx-axi.git') + got=$(fm_gh_config_dir_for_spawn "$home" "$repo") \ + || fail "knowttl repo did not resolve a spawn config dir" + [ "$got" = "$home/data/gh-config/knowttl" ] \ + || fail "knowttl spawn dir resolved to '$got'" + + # pwxgh via https origin. + repo=$(make_repo_with_origin pwxgh-repo 'https://github.com/powerex-development/ops.cs.azure-epac.git') + got=$(fm_gh_config_dir_for_spawn "$home" "$repo") \ + || fail "pwxgh repo did not resolve a spawn config dir" + [ "$got" = "$home/data/gh-config/pwxgh" ] \ + || fail "pwxgh spawn dir resolved to '$got'" + + pass "fm_gh_config_dir_for_spawn carries the expected GH_CONFIG_DIR for a mapped project" +} + +test_spawn_dir_secondmate_reaches_primary() { + # A secondmate-home worker reaches the primary home's account configs. + local primary="$TMP_ROOT/spawn-primary" + local sm="$TMP_ROOT/spawn-sm" + mkdir -p "$primary/data/gh-config/pwxgh" + mkdir -p "$sm" + printf 'seed-id\n' > "$sm/.fm-secondmate-home" + cat > "$sm/.fm-secondmate-parent" <