Skip to content

feat(bin): read-only Bitbucket forge credential with session-start check#953

Open
mattwwatson wants to merge 3 commits into
kunchenguid:mainfrom
mattwwatson:up/bb-credential
Open

feat(bin): read-only Bitbucket forge credential with session-start check#953
mattwwatson wants to merge 3 commits into
kunchenguid:mainfrom
mattwwatson:up/bb-credential

Conversation

@mattwwatson

@mattwwatson mattwwatson commented Jul 24, 2026

Copy link
Copy Markdown

Intent

Upstream contribution from the mattwwatson/firstmate fork, first of three sequenced Bitbucket increments: give firstmate a read-only credential path for Bitbucket-hosted repositories, which it previously had no way to read at all (GitHub went through gh). Adds bin/fm-forge-credential.sh, a login-keychain-backed read-only resolver that never puts the secret on argv or stdout and hands it to curl through a config on stdin, with api-get/forge-of/repo-of helpers, plus a FORGE_CREDENTIAL: session-start diagnostic that probes one repository per forge so a missing, stalled, or unusable credential is reported up front rather than at the point of use. Rebased onto current upstream/main with one docs/scripts.md table conflict hand-merged (upstream's newer fm-pr-lib/fm-pr-poll wording kept, only the new credential row added). Test fixtures use neutral example-team/example-repo identities and the tests are fully stubbed and offline. Suite passes 26/26 locally on the upstream base. Note for reviewers: tests/fm-forge-credential.test.sh is a large new suite that will land in the portable serial lane unless you would prefer shard placement or an isolation proof - happy to follow your preference.

Part of a three-PR series adding Bitbucket support

This is the first of three sequenced PRs that together add Bitbucket support to firstmate. They are deliberately kept separate rather than combined, because together they are ~4,800 lines and each stage is independently reviewable and green on its own:

  1. This PR - read-only Bitbucket credential resolver (bin/fm-forge-credential.sh) and the FORGE_CREDENTIAL: session-start diagnostic.
  2. Next - Bitbucket merge detection and build status, joining GitHub and GitLab in the existing hardened PR-poll architecture.
  3. Last - the guarded Bitbucket PR merge action, gated on a real credential scope probe.

Each later PR will link back to the earlier ones. Nothing in this PR depends on the later two, so it can be reviewed and merged on its own.

What Changed

  • Adds bin/fm-forge-credential.sh, a login-keychain-backed read-only credential resolver for Bitbucket-hosted repositories (a forge firstmate previously had no way to read at all, since GitHub goes through gh). It keeps the secret off argv and stdout by handing it to curl through a config on stdin, fixes the API host per forge so no caller can redirect the credential elsewhere, and exposes check, api-get, forge-of, repo-of, and probe-target behind a documented exit-code contract (absent, incomplete, rejected, no store, inconclusive, not-visible, unexpected, store timeout, store refused).
  • Adds a detect-only FORGE_CREDENTIAL: <forge>: <reason> session-start diagnostic in bin/fm-bootstrap.sh. It scans tracked clones' origin remotes, asks the resolver once for the single repository worth probing, and spends at most one bounded request per forge per session start, so a missing, stalled, or unusable credential surfaces up front instead of when a PR step fails. "No credential store" and "credential cannot see repository" are reported once per home under state/forge-credential-<outcome>.<forge>, and a lock-refused session reports without recording. Missing-tool reporting now routes through a report_missing_tool helper so curl is named at most once across checks.
  • Adds tests/fm-forge-credential.test.sh (fully stubbed and offline, neutral example-team/example-repo fixtures) covering the resolver and the bootstrap detection path, plus docs: a forge-credentials section in docs/configuration.md, the new script row in docs/scripts.md, the FORGE_CREDENTIAL handling playbook in the bootstrap-diagnostics skill, and matching AGENTS.md/README.md pointers.

Follow-up review pass corrected credential-store error classification (distinguishing a store that refused the read from a genuinely absent entry) and forge-error classification (mapping 5xx and rate-limit refusals to inconclusive rather than emitting a session-start line for an unreachable forge). Tests: the new suite passes 31/31, --changed --base 587f591 runs 42 suites with only the pre-existing timing-flaky tests/fm-test-run.test.sh failing (passes 3/3 in isolation; this branch touches no test-runner code), and the lane coverage guard is green with the new suite in the portable serial lane. One open info-level review note remains: probe-target drops a final non-newline-terminated stdin line — latent only, as the in-tree caller always terminates.

Risk Assessment

✅ Low: All four prior findings are fixed at the root cause with dedicated behavioral tests rather than patched at the symptom, the forge table now has a single owner, and the one delicate addition beyond the requested fixes — the single-read rewrite of the FIFO store read — checks out against bash 3.2 semantics and every value shape the suite exercises, leaving only a latent unterminated-final-line nit in a subcommand whose sole caller always terminates its input.

Testing

Ran the new fm-forge-credential suite (31/31), the coverage-lane guard, and the full changed-file selection (42 suites), then demonstrated the feature end-to-end with an offline CLI transcript covering every exit-code branch of the resolver, proof that the credential reaches curl via stdin config and never argv, and the actual FORGE_CREDENTIAL: lines a captain sees from bin/fm-bootstrap.sh including once-per-home suppression, detect-only handover, incident silence, and the GitHub-only home staying quiet. No screenshot is included because the change has no rendered UI surface — the end-user experience is a shell command and a session-start digest line, which the transcript captures verbatim. The only failure was tests/fm-test-run.test.sh, a wall-clock-race scheduler test unrelated to this change that passed 3/3 on re-run.

Evidence: End-to-end CLI + session-start transcript (full)

============================================================
1. URL -> forge and repository (forge-of / repo-of)
============================================================

$ fm-forge-credential.sh forge-of git@bitbucket.org:example-team/example-repo.git
bitbucket
[exit 0]
$ fm-forge-credential.sh repo-of git@bitbucket.org:example-team/example-repo.git
example-team/example-repo
[exit 0]

$ fm-forge-credential.sh forge-of https://bitbucket.org/example-team/example-repo.git
bitbucket
[exit 0]
$ fm-forge-credential.sh repo-of https://bitbucket.org/example-team/example-repo.git
example-team/example-repo
[exit 0]

$ fm-forge-credential.sh forge-of https://github.com/owner/repo.git
github
[exit 0]
$ fm-forge-credential.sh repo-of https://github.com/owner/repo.git
owner/repo
[exit 0]

$ fm-forge-credential.sh forge-of https://gitlab.com/owner/repo.git
<no output>
[exit 1]
$ fm-forge-credential.sh repo-of https://gitlab.com/owner/repo.git
<no output>
[exit 1]

============================================================
2. check - the happy path and every refusal, with its own exit code
============================================================

--- working credential + real repository read (expect silence, exit 0)
$ fm-forge-credential.sh check bitbucket example-team/example-repo
[exit 0]

--- no repository given: local store proof only, no request (exit 0)
$ fm-forge-credential.sh check bitbucket
[exit 0]

--- token entry missing from the keychain (exit 3 absent)
$ fm-forge-credential.sh check bitbucket example-team/example-repo
error: keychain entry firstmate-bitbucket-token is absent from the login keychain
[exit 3]

--- entry present but empty (exit 4 incomplete)
$ fm-forge-credential.sh check bitbucket example-team/example-repo
error: keychain entry firstmate-bitbucket-token is present but empty
[exit 4]

--- keychain refuses the unattended read (exit 11 store refused)
$ fm-forge-credential.sh check bitbucket example-team/example-repo
error: keychain entry firstmate-bitbucket-token could not be read: the store refused the read (status 51) rather than reporting the entry missing, as it does when the item's access control forbids an unattended read; re-cache it to allow one
[exit 11]

--- keychain never answers - dialog no daemon can click (exit 10, bounded)
$ fm-forge-credential.sh check bitbucket example-team/example-repo
error: keychain entry firstmate-bitbucket-token did not answer within 2s: the stored item is prompting instead of answering, so re-cache it to allow an unattended read
[exit 10]

--- credential rejected by Bitbucket, HTTP 401 (exit 5 rejected)
$ fm-forge-credential.sh check bitbucket example-team/example-repo
error: credential rejected by bitbucket (HTTP 401): the token is invalid, revoked, or expired
[exit 5]

--- under-scoped credential, HTTP 403 (exit 5 rejected)
$ fm-forge-credential.sh check bitbucket example-team/example-repo
error: credential refused by bitbucket (HTTP 403): it lacks the required read scopes
[exit 5]

--- authenticated but repository not visible, HTTP 404 (exit 8)
$ fm-forge-credential.sh check bitbucket example-team/example-repo
error: the credential authenticated but cannot see bitbucket repository example-team/example-repo (HTTP 404): its account or scopes may be wrong, or the repository moved
[exit 8]

--- Bitbucket incident, HTTP 503 (exit 7 inconclusive - never a verdict)
$ fm-forge-credential.sh check bitbucket example-team/example-repo
error: api.bitbucket.org could not answer the request (HTTP 503), so nothing was proved about the credential
[exit 7]

--- transport failure (exit 7 inconclusive)
$ fm-forge-credential.sh check bitbucket example-team/example-repo
error: no usable response from api.bitbucket.org (curl exit 7)
[exit 7]

--- GitHub: firstmate holds no credential, gh owns it (exit 2)
$ fm-forge-credential.sh check github owner/repo
error: github has no firstmate-held credential; GitHub access is owned by the gh CLI
[exit 2]

============================================================
3. api-get - one authenticated read-only GET, body on stdout
============================================================

--- read a repository through the credential
$ fm-forge-credential.sh api-get bitbucket /2.0/repositories/example-team/example-repo
{"slug":"example-repo","full_name":"example-team/example-repo","is_private":true}
[exit 0]

--- api-get with a URL instead of a path is refused (exit 2)
$ fm-forge-credential.sh api-get bitbucket https://evil.example.com/steal
error: api path must start with '/'
[exit 2]

--- how the secret actually reached curl
curl argv (must contain NO secret):
  --silent --config - --request GET --header Accept: application/json --max-time 10 --output /var/folders/mz/9drvc3ms7w9c8vzk0j1gj2vm0000gp/T//fm-forge-body.rsy3Uw --write-out %{http_code} https://api.bitbucket.org/2.0/repositories/example-team/example-repo
curl stdin config (this is where the credential travels):
  user = "<USER>:<SECRET-PRESENT-ON-STDIN>"
grep for the secret in argv: not present (good)

============================================================
4. session start: the FORGE_CREDENTIAL diagnostic a captain sees
============================================================

--- Bitbucket clone tracked, token entry missing
$ bin/fm-bootstrap.sh          # what the session-start digest prints
FORGE_CREDENTIAL: bitbucket: keychain entry firstmate-bitbucket-token is absent from the login keychain

--- same home, credential rejected by Bitbucket (HTTP 401)
$ bin/fm-bootstrap.sh          # what the session-start digest prints
FORGE_CREDENTIAL: bitbucket: credential rejected by bitbucket (HTTP 401): the token is invalid, revoked, or expired

--- same home, repository not visible to the credential (HTTP 404) - first session
$ bin/fm-bootstrap.sh          # what the session-start digest prints
FORGE_CREDENTIAL: bitbucket: the credential authenticated but cannot see bitbucket repository example-team/example-repo (HTTP 404): its account or scopes may be wrong, or the repository moved

--- same home, same 404 - already reported once, now silent
$ bin/fm-bootstrap.sh          # what the session-start digest prints
<silent - nothing for the captain to do>

--- same home, Bitbucket incident (HTTP 503) - inconclusive, never a verdict
$ bin/fm-bootstrap.sh          # what the session-start digest prints
<silent - nothing for the captain to do>

--- same home, working credential
$ bin/fm-bootstrap.sh          # what the session-start digest prints
<silent - nothing for the captain to do>

(the next two runs point the resolver at a machine with no login keychain at all)

--- no credential store on this platform - first session
$ bin/fm-bootstrap.sh          # what the session-start digest prints
FORGE_CREDENTIAL: bitbucket: no credential store on this platform, so bitbucket merge and build checks are unavailable here

--- no credential store - already told once, now silent
$ bin/fm-bootstrap.sh          # what the session-start digest prints
<silent - nothing for the captain to do>

(a lock-refused session reports the news without consuming the one report)

--- lock-refused session (FM_BOOTSTRAP_DETECT_ONLY=1) - reports
$ bin/fm-bootstrap.sh          # what the session-start digest prints
FORGE_CREDENTIAL: bitbucket: no credential store on this platform, so bitbucket merge and build checks are unavailable here

--- the session that CAN act still gets told
$ bin/fm-bootstrap.sh          # what the session-start digest prints
FORGE_CREDENTIAL: bitbucket: no credential store on this platform, so bitbucket merge and build checks are unavailable here

--- GitHub-only home is never asked about a credential firstmate does not hold
$ bin/fm-bootstrap.sh          # what the session-start digest prints
<silent - nothing for the captain to do>

--- no credential value anywhere in this transcript
sandbox: /var/folders/mz/9drvc3ms7w9c8vzk0j1gj2vm0000gp/T//fm-forge-demo.q2ov8y
Evidence: Reproducible offline demo script (stubbed keychain + curl)
#!/usr/bin/env bash
# Manual end-to-end demonstration of bin/fm-forge-credential.sh and the
# FORGE_CREDENTIAL session-start diagnostic, driven exactly as an end user
# (captain) would experience them: real script invocations, real exit codes,
# real stderr wording, real session-start digest lines.
#
# Fully offline: a fake `security` stands in for the login keychain and a fake
# `curl` stands in for api.bitbucket.org. Nothing here touches a real
# credential or a real network.
set -u

ROOT=${ROOT:?set ROOT to the firstmate checkout}
SANDBOX=$(mktemp -d "${TMPDIR:-/tmp}/fm-forge-demo.XXXXXX")
FAKEBIN="$SANDBOX/bin"
mkdir -p "$FAKEBIN"

DUMMY_USER='demo@example.invalid'
DUMMY_SECRET='demo-token-value-SHOULD-NEVER-APPEAR'

# --- fake login keychain -----------------------------------------------------
cat > "$FAKEBIN/security" <<SH
#!/usr/bin/env bash
[ "\${1:-}" = find-generic-password ] || exit 1
service=
while [ "\$#" -gt 0 ]; do
  case "\$1" in
    -s) service=\$2; shift 2 ;;
    *) shift ;;
  esac
done
case "\$service" in
  firstmate-bitbucket-email) mode=\${FAKE_KEYCHAIN_USER:-ok}; value='$DUMMY_USER' ;;
  firstmate-bitbucket-token) mode=\${FAKE_KEYCHAIN_SECRET:-ok}; value='$DUMMY_SECRET' ;;
  *) exit 44 ;;
esac
case "\$mode" in
  ok) printf '%s\n' "\$value" ;;
  absent) exit 44 ;;
  refused) exit 51 ;;
  empty) printf '\n' ;;
  stall) exec sleep 20 ;;
esac
SH
chmod +x "$FAKEBIN/security"

# --- fake api.bitbucket.org --------------------------------------------------
cat > "$FAKEBIN/curl" <<SH
#!/usr/bin/env bash
printf '%s\n' "\$*" >> "\${FAKE_CURL_ARGV_LOG:-/dev/null}"
config=\$(cat)
printf '%s\n' "\$config" >> "\${FAKE_CURL_STDIN_LOG:-/dev/null}"
out=
while [ "\$#" -gt 0 ]; do
  case "\$1" in
    --output) out=\$2; shift 2 ;;
    *) shift ;;
  esac
done
[ -z "\$out" ] || printf '%s\n' '{"slug":"example-repo","full_name":"example-team/example-repo","is_private":true}' > "\$out"
if [ -n "\${FAKE_CURL_EXIT:-}" ] && [ "\${FAKE_CURL_EXIT}" -ne 0 ]; then
  printf '%s' 000; exit "\$FAKE_CURL_EXIT"
fi
printf '%s' "\${FAKE_CURL_STATUS:-200}"
SH
chmod +x "$FAKEBIN/curl"

CRED="$ROOT/bin/fm-forge-credential.sh"

hdr() { printf '\n============================================================\n%s\n============================================================\n' "$1"; }

# Run the resolver the way a captain would, showing the command, everything it
# printed, and the exit code the contract promises.
run() {  # <label> <env-assignments...> -- <args...>
  local label=$1; shift
  local envs=() args=()
  while [ "$#" -gt 0 ]; do
    case "$1" in --) shift; break ;; esac
    envs+=("$1"); shift
  done
  args=("$@")
  printf '\n--- %s\n' "$label"
  printf '$ fm-forge-credential.sh %s\n' "${args[*]}"
  set +e
  env PATH="$FAKEBIN:/usr/bin:/bin:/usr/sbin:/sbin" \
    FM_FORGE_KEYCHAIN_TOOL_OVERRIDE="$FAKEBIN/security" \
    "${envs[@]}" "$CRED" "${args[@]}" 2>&1
  local status=$?
  set -e
  printf '[exit %d]\n' "$status"
}

hdr "1. URL -> forge and repository (forge-of / repo-of)"
for url in \
  'git@bitbucket.org:example-team/example-repo.git' \
  'https://bitbucket.org/example-team/example-repo.git' \
  'https://github.com/owner/repo.git' \
  'https://gitlab.com/owner/repo.git'
do
  printf '\n$ fm-forge-credential.sh forge-of %s\n' "$url"
  out=$(env PATH="$FAKEBIN:/usr/bin:/bin" "$CRED" forge-of "$url" 2>&1); st=$?
  printf '%s\n[exit %d]\n' "${out:-<no output>}" "$st"
  printf '$ fm-forge-credential.sh repo-of %s\n' "$url"
  out=$(env PATH="$FAKEBIN:/usr/bin:/bin" "$CRED" repo-of "$url" 2>&1); st=$?
  printf '%s\n[exit %d]\n' "${out:-<no output>}" "$st"
done

hdr "2. check - the happy path and every refusal, with its own exit code"
run "working credential + real repository read (expect silence, exit 0)" \
  FAKE_CURL_STATUS=200 -- check bitbucket example-team/example-repo
run "no repository given: local store proof only, no request (exit 0)" \
  -- check bitbucket
run "token entry missing from the keychain (exit 3 absent)" \
  FAKE_KEYCHAIN_SECRET=absent -- check bitbucket example-team/example-repo
run "entry present but empty (exit 4 incomplete)" \
  FAKE_KEYCHAIN_SECRET=empty -- check bitbucket example-team/example-repo
run "keychain refuses the unattended read (exit 11 store refused)" \
  FAKE_KEYCHAIN_SECRET=refused -- check bitbucket example-team/example-repo
run "keychain never answers - dialog no daemon can click (exit 10, bounded)" \
  FAKE_KEYCHAIN_SECRET=stall FM_FORGE_KEYCHAIN_TIMEOUT=2 -- check bitbucket example-team/example-repo
run "credential rejected by Bitbucket, HTTP 401 (exit 5 rejected)" \
  FAKE_CURL_STATUS=401 -- check bitbucket example-team/example-repo
run "under-scoped credential, HTTP 403 (exit 5 rejected)" \
  FAKE_CURL_STATUS=403 -- check bitbucket example-team/example-repo
run "authenticated but repository not visible, HTTP 404 (exit 8)" \
  FAKE_CURL_STATUS=404 -- check bitbucket example-team/example-repo
run "Bitbucket incident, HTTP 503 (exit 7 inconclusive - never a verdict)" \
  FAKE_CURL_STATUS=503 -- check bitbucket example-team/example-repo
run "transport failure (exit 7 inconclusive)" \
  FAKE_CURL_EXIT=7 -- check bitbucket example-team/example-repo
run "GitHub: firstmate holds no credential, gh owns it (exit 2)" \
  -- check github owner/repo

hdr "3. api-get - one authenticated read-only GET, body on stdout"
ARGV_LOG="$SANDBOX/curl-argv.log"; STDIN_LOG="$SANDBOX/curl-stdin.log"
: > "$ARGV_LOG"; : > "$STDIN_LOG"
run "read a repository through the credential" \
  FAKE_CURL_STATUS=200 FAKE_CURL_ARGV_LOG="$ARGV_LOG" FAKE_CURL_STDIN_LOG="$STDIN_LOG" \
  -- api-get bitbucket /2.0/repositories/example-team/example-repo
run "api-get with a URL instead of a path is refused (exit 2)" \
  -- api-get bitbucket https://evil.example.com/steal

printf '\n--- how the secret actually reached curl\n'
printf 'curl argv (must contain NO secret):\n'
sed 's/^/  /' "$ARGV_LOG"
printf 'curl stdin config (this is where the credential travels):\n'
sed "s/$DUMMY_SECRET/<SECRET-PRESENT-ON-STDIN>/; s/$DUMMY_USER/<USER>/; s/^/  /" "$STDIN_LOG"
printf 'grep for the secret in argv: '
if grep -q "$DUMMY_SECRET" "$ARGV_LOG"; then printf 'LEAKED\n'; else printf 'not present (good)\n'; fi

# --- session start -----------------------------------------------------------
hdr "4. session start: the FORGE_CREDENTIAL diagnostic a captain sees"

for t in tmux node gh-axi chrome-devtools-axi lavish-axi quota-axi gh; do
  printf '#!/usr/bin/env bash\nexit 0\n' > "$FAKEBIN/$t"; chmod +x "$FAKEBIN/$t"
done
cat > "$FAKEBIN/treehouse" <<'SH'
#!/usr/bin/env bash
if [ "${1:-}" = get ] && [ "${2:-}" = --help ]; then printf '%s\n' 'Usage: treehouse get [--lease]'; fi
exit 0
SH
chmod +x "$FAKEBIN/treehouse"
printf '#!/usr/bin/env bash\n[ "${1:-}" = --version ] && printf "%%s\\n" "no-mistakes version v1.31.2"\nexit 0\n' > "$FAKEBIN/no-mistakes"
chmod +x "$FAKEBIN/no-mistakes"
cat > "$FAKEBIN/tasks-axi" <<'SH'
#!/usr/bin/env bash
case "${1:-}:${2:-}" in
  --version:*) printf '%s\n' 0.1.1 ;;
  update:--help) printf '%s\n' '  --archive-body' ;;
  mv:--help) printf '%s\n' 'usage: tasks-axi mv <id> [<id>...] --to <path-or-dir>' ;;
esac
exit 0
SH
chmod +x "$FAKEBIN/tasks-axi"

make_home() {  # <home> <name=url>...
  local home=$1; shift
  mkdir -p "$home/config" "$home/projects"
  printf '%s\n' manual > "$home/config/backlog-backend"
  local spec
  for spec in "$@"; do
    git init -q "$home/projects/${spec%%=*}"
    git -C "$home/projects/${spec%%=*}" remote add origin "${spec#*=}"
  done
}

boot() {  # <label> <home> <env...>
  local label=$1 home=$2; shift 2
  printf '\n--- %s\n' "$label"
  printf '$ bin/fm-bootstrap.sh          # what the session-start digest prints\n'
  set +e
  # stdout only: the digest is what the captain sees. (This sandbox home is not
  # a git checkout, so the unrelated tangle check writes noise to stderr.)
  out=$(env PATH="$FAKEBIN:/usr/bin:/bin:/usr/sbin:/sbin" \
    FM_HOME="$home" FM_ROOT_OVERRIDE="$home" \
    FM_FORGE_KEYCHAIN_TOOL_OVERRIDE="$FAKEBIN/security" \
    FM_FAKE_TREEHOUSE_LEASE_HELP=1 "$@" "$ROOT/bin/fm-bootstrap.sh" 2>/dev/null)
  set -e
  if [ -n "$out" ]; then printf '%s\n' "$out"; else printf '<silent - nothing for the captain to do>\n'; fi
}

BB_HOME="$SANDBOX/home-bitbucket"
make_home "$BB_HOME" \
  'example-repo=git@bitbucket.org:example-team/example-repo.git' \
  'gh-only=https://github.com/owner/repo.git'

boot "Bitbucket clone tracked, token entry missing" "$BB_HOME" FAKE_KEYCHAIN_SECRET=absent
boot "same home, credential rejected by Bitbucket (HTTP 401)" "$BB_HOME" FAKE_CURL_STATUS=401
boot "same home, repository not visible to the credential (HTTP 404) - first session" "$BB_HOME" FAKE_CURL_STATUS=404
boot "same home, same 404 - already reported once, now silent" "$BB_HOME" FAKE_CURL_STATUS=404
boot "same home, Bitbucket incident (HTTP 503) - inconclusive, never a verdict" "$BB_HOME" FAKE_CURL_STATUS=503
boot "same home, working credential" "$BB_HOME" FAKE_CURL_STATUS=200

NOSTORE_HOME="$SANDBOX/home-nostore"
make_home "$NOSTORE_HOME" 'example-repo=git@bitbucket.org:example-team/example-repo.git'
printf '\n(the next two runs point the resolver at a machine with no login keychain at all)\n'
boot "no credential store on this platform - first session" "$NOSTORE_HOME" \
  FM_FORGE_KEYCHAIN_TOOL_OVERRIDE="$SANDBOX/no-such-store"
boot "no credential store - already told once, now silent" "$NOSTORE_HOME" \
  FM_FORGE_KEYCHAIN_TOOL_OVERRIDE="$SANDBOX/no-such-store"

LOCKED_HOME="$SANDBOX/home-locked"
make_home "$LOCKED_HOME" 'example-repo=git@bitbucket.org:example-team/example-repo.git'
printf '\n(a lock-refused session reports the news without consuming the one report)\n'
boot "lock-refused session (FM_BOOTSTRAP_DETECT_ONLY=1) - reports" "$LOCKED_HOME" \
  FM_BOOTSTRAP_DETECT_ONLY=1 FM_FORGE_KEYCHAIN_TOOL_OVERRIDE="$SANDBOX/no-such-store"
boot "the session that CAN act still gets told" "$LOCKED_HOME" \
  FM_FORGE_KEYCHAIN_TOOL_OVERRIDE="$SANDBOX/no-such-store"

GH_HOME="$SANDBOX/home-github"
make_home "$GH_HOME" 'gh-only=https://github.com/owner/repo.git'
boot "GitHub-only home is never asked about a credential firstmate does not hold" "$GH_HOME" FAKE_KEYCHAIN_SECRET=absent

printf '\n--- no credential value anywhere in this transcript\n'
printf 'sandbox: %s\n' "$SANDBOX"
Evidence: Resolver exit-code contract, exercised
$ fm-forge-credential.sh check bitbucket example-team/example-repo # working credential
[exit 0]

$ fm-forge-credential.sh check bitbucket example-team/example-repo # token entry missing
error: keychain entry firstmate-bitbucket-token is absent from the login keychain
[exit 3]

$ fm-forge-credential.sh check bitbucket example-team/example-repo # entry present but empty
error: keychain entry firstmate-bitbucket-token is present but empty
[exit 4]

$ fm-forge-credential.sh check bitbucket example-team/example-repo # store refuses unattended read
error: keychain entry firstmate-bitbucket-token could not be read: the store refused the read (status 51) rather than reporting the entry missing, as it does when the item's access control forbids an unattended read; re-cache it to allow one
[exit 11]

$ fm-forge-credential.sh check bitbucket example-team/example-repo # store never answers (bounded)
error: keychain entry firstmate-bitbucket-token did not answer within 2s: the stored item is prompting instead of answering, so re-cache it to allow an unattended read
[exit 10]

$ fm-forge-credential.sh check bitbucket example-team/example-repo # HTTP 401
error: credential rejected by bitbucket (HTTP 401): the token is invalid, revoked, or expired
[exit 5]

$ fm-forge-credential.sh check bitbucket example-team/example-repo # HTTP 404
error: the credential authenticated but cannot see bitbucket repository example-team/example-repo (HTTP 404): its account or scopes may be wrong, or the repository moved
[exit 8]

$ fm-forge-credential.sh check bitbucket example-team/example-repo # HTTP 503
error: api.bitbucket.org could not answer the request (HTTP 503), so nothing was proved about the credential
[exit 7]

$ fm-forge-credential.sh check github owner/repo
error: github has no firstmate-held credential; GitHub access is owned by the gh CLI
[exit 2]
Evidence: Secret never on argv — it travels on curl's stdin config
$ fm-forge-credential.sh api-get bitbucket /2.0/repositories/example-team/example-repo
{"slug":"example-repo","full_name":"example-team/example-repo","is_private":true}
[exit 0]

$ fm-forge-credential.sh api-get bitbucket https://evil.example.com/steal
error: api path must start with '/'
[exit 2]

curl argv as actually invoked:
--silent --config - --request GET --header Accept: application/json --max-time 10 \
--output /var/.../fm-forge-body.l7eKe9 --write-out %{http_code} \
https://api.bitbucket.org/2.0/repositories/example-team/example-repo

curl stdin config (redacted for this artifact):
user = "<USER>:<SECRET-PRESENT-ON-STDIN>"

grep for the secret in argv: not present (good)
grep -c '<dummy secret>' over the whole transcript: 0
Evidence: Session-start digest (bin/fm-bootstrap.sh stdout, one home over successive sessions)
--- Bitbucket clone tracked, token entry missing
FORGE_CREDENTIAL: bitbucket: keychain entry firstmate-bitbucket-token is absent from the login keychain

--- same home, credential rejected by Bitbucket (HTTP 401)
FORGE_CREDENTIAL: bitbucket: credential rejected by bitbucket (HTTP 401): the token is invalid, revoked, or expired

--- same home, repository not visible (HTTP 404) - first session
FORGE_CREDENTIAL: bitbucket: the credential authenticated but cannot see bitbucket repository example-team/example-repo (HTTP 404): its account or scopes may be wrong, or the repository moved

--- same home, same 404 - already reported once, now silent
<silent - nothing for the captain to do>

--- same home, Bitbucket incident (HTTP 503) - inconclusive, never a verdict
<silent - nothing for the captain to do>

--- same home, working credential
<silent - nothing for the captain to do>

--- no credential store on this platform - first session
FORGE_CREDENTIAL: bitbucket: no credential store on this platform, so bitbucket merge and build checks are unavailable here

--- no credential store - already told once, now silent
<silent - nothing for the captain to do>

--- lock-refused session (FM_BOOTSTRAP_DETECT_ONLY=1) - reports without consuming
FORGE_CREDENTIAL: bitbucket: no credential store on this platform, so bitbucket merge and build checks are unavailable here
--- the session that CAN act still gets told
FORGE_CREDENTIAL: bitbucket: no credential store on this platform, so bitbucket merge and build checks are unavailable here

--- GitHub-only home is never asked about a credential firstmate does not hold
<silent - nothing for the captain to do>
- Outcome: ⚠️ 1 warning across 1 run (16m2s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 1 info
  • ⚠️ bin/fm-forge-credential.sh:295 - read_keychain_half treats every non-zero exit from security as "keychain entry <service> is absent from the login keychain". macOS find-generic-password returns 44 for not-found but 51 (errSecInteractionNotAllowed) when the stored item's access control requires interaction and the session cannot provide it, and 36/25293 for other auth failures. Failure scenario: firstmate runs over SSH or from launchd against an item whose ACL demands confirmation; security exits 51 immediately (it does not block, so the STORE_TIMEOUT watchdog never fires and exit 10 is never reached), and session start prints a FORGE_CREDENTIAL line telling the captain the entry is absent from the keychain when it is present and merely unreadable unattended. The captain then goes looking for a missing entry instead of re-caching an existing one. Consider distinguishing the store's exit status: 44 keeps EX_ABSENT, any other non-zero status gets its own reason naming that the store refused the read rather than asserting the entry does not exist.
  • ⚠️ bin/fm-forge-credential.sh:429 - forge_get classifies every non-2xx/401/403/404/000 status as EX_UNEXPECTED (9), and forge_credential_report has no case for 9, so it prints the reason on every session start with no report-once record. Failure scenario: Bitbucket returns 503 during an incident or 429 under API rate limiting; the credential is fine, but every session start emits FORGE_CREDENTIAL: bitbucket: unexpected response from bitbucket (HTTP 503) and loads the bootstrap-diagnostics skill for a problem that is neither a credential fault nor captain-actionable. This contradicts the contract the change itself documents at docs/configuration.md:321 ("It stays silent when the forge cannot be reached: being offline is not a credential fault") and at bin/fm-bootstrap.sh:78 — a 503/429 is the forge being unreachable in every sense that matters here. Consider mapping 5xx and 429 to EX_INCONCLUSIVE (7) alongside the transport-error path, leaving exit 9 for genuinely unclassifiable statuses.
  • ℹ️ bin/fm-bootstrap.sh:590 - forge_credential_check hardcodes [ &#34;$forge&#34; = bitbucket ] || continue, duplicating knowledge that bin/fm-forge-credential.sh's forge_supported/forge_keychain_service are documented as solely owning (docs/configuration.md:297: "its header owns the entry names, subcommands, and exit-code contract, and every caller goes through it rather than reading a store directly"). Failure scenario: a later increment adds a second firstmate-held forge to the resolver's forge table; the resolver handles it correctly but bootstrap's scan skips every clone on it, so the session-start diagnostic silently never fires for that forge and the regression is invisible until a PR step fails — exactly the failure this check exists to pre-empt. Consider a resolver query (e.g. holds-credential &lt;forge&gt; returning 0/1) so the forge table stays single-owned.
  • ℹ️ bin/fm-bootstrap.sh:586 - The probe-target scan spawns a fresh fm-forge-credential.sh forge-of bash process for every tracked clone (plus repo-of for each Bitbucket hit), on top of the existing git remote get-url per clone, and only breaks once a usable Bitbucket remote is found. Failure scenario: a GitHub-only home tracking 30 clones runs 30 extra bash interpreter startups at every single session start and can never produce a FORGE_CREDENTIAL line, adding several hundred milliseconds of latency to a check that cannot apply to it. The change is otherwise careful about session-start cost ("the scan must never turn a home's clone count into a session-start request count" at line 583), but the process cost per clone is unbounded in the same way. A cheap pre-filter on the remote URL before spawning the resolver, or one resolver invocation fed all remotes, would remove it.

🔧 Fix: fix(forge): correct credential-store and forge-error classification
1 info still open:

  • ℹ️ bin/fm-forge-credential.sh:608 - cmd_probe_target's while IFS= read -r url; do ... done silently discards a final line that is not newline-terminated: read returns non-zero for the trailing partial record, so the loop body never runs for it. The header documents the contract as "Read git remote URLs on stdin, one per line" (line 33), with no requirement that the last line be terminated. Failure scenario: a caller pipes remotes without a trailing newline (e.g. git config --get-all remote.origin.url | tr &#39;\n&#39; &#39; &#39;-style plumbing, or printf &#39;%s&#39; &#34;$url&#34; for a single remote) and the only Bitbucket remote is the last one; probe-target exits 1, forge_credential_check returns silently at bin/fm-bootstrap.sh:598, and the session-start credential check never runs for a home that does track a Bitbucket repository. The current in-tree caller always appends a newline (bin/fm-bootstrap.sh:586-587), so this is latent rather than live, but probe-target is now a documented subcommand with its own usage line. while IFS= read -r url || [ -n &#34;$url&#34; ]; do closes it.
⚠️ **Test** - 1 warning
  • ⚠️ tests/fm-test-run.test.sh:540 - tests/fm-test-run.test.sh ("jobs=2 must refill the first completed slot") is timing-flaky and failed once during the full changed-file run. Its fixture races a sleep 0.5 script against a sleep 0.05 one (tests/fm-test-run.test.sh:514-532) and asserts the third script starts before the slow one finishes, so it fails under machine load. It passed 3/3 on re-run in isolation, and the change touches none of bin/fm-test-run.sh, so it is unrelated to this commit — flagged only so it is not mistaken for a regression.
  • bash bin/fm-test-run.sh tests/fm-forge-credential.test.sh — 31/31 assertions pass (resolver + bootstrap detection)
  • bash bin/fm-test-run.sh --changed --base 587f591 — 42 suites, only the unrelated timing-flaky tests/fm-test-run.test.sh failed
  • bash bin/fm-test-run.sh --check-coverage — lane coverage guard green with the new suite in the portable serial lane (total=97 parallel=30 serial=58 herdr=9)
  • bash bin/fm-test-run.sh tests/fm-test-run.test.sh x3 — flake reproduction check, passed every time in isolation
  • Manual end-to-end demo: ROOT=$PWD bash demo-forge-credential.sh driving bin/fm-forge-credential.sh check/api-get/forge-of/repo-of and bin/fm-bootstrap.sh against a stubbed keychain and stubbed curl
  • Manual leak check: grep -c &#39;&lt;dummy secret&gt;&#39; forge-credential-demo.txt → 0, plus explicit inspection of the recorded curl argv vs curl stdin config
⚠️ **Document** - 1 info
  • ℹ️ docs/fm-test-portable-shards.md:68 - docs/fm-test-portable-shards.md's serial-lane timing rationale ("Measured serial remainder wall ... about 13 minutes", and the 20-minute portable-serial timeout justified by it) predates this change's 1031-line tests/fm-forge-credential.test.sh, which lands in that lane by the rule at line 66. The prose is not falsified — it is explicitly a Phase 1 measurement snapshot — and re-measuring requires CI timing artifacts I cannot produce here, so I left it alone rather than hand-editing a measured number. Worth refreshing from the next fm-test-timing artifact on main if the serial lane's wall clock has moved materially.
✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

firstmate could read GitHub through gh but had no way to read a
Bitbucket repository, so Bitbucket-hosted projects had no credential
path at all. Add bin/fm-forge-credential.sh: a login-keychain-backed
read-only credential resolver that never places the secret on argv or
stdout and passes it to curl through a config on stdin, plus api-get,
forge-of, and repo-of helpers for read-only forge API calls. Session
start gains a FORGE_CREDENTIAL: diagnostic that probes one repository
per forge and reports a missing, stalled, or unusable credential
instead of failing later at the point of use.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant