diff --git a/.agents/skills/bootstrap-diagnostics/SKILL.md b/.agents/skills/bootstrap-diagnostics/SKILL.md index 95932444f8..1ab709b2eb 100644 --- a/.agents/skills/bootstrap-diagnostics/SKILL.md +++ b/.agents/skills/bootstrap-diagnostics/SKILL.md @@ -2,7 +2,7 @@ name: bootstrap-diagnostics description: >- Agent-only handling playbook for session-start bootstrap diagnostics. - Use whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, FLEET_SYNC, NETWORK_CHECKS, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, SECONDMATE_HANDOFF, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh or bin/fm-startup-network.sh run prints one of those lines. + Use whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line - MISSING, MISSING_MANUAL, BACKEND_INVALID, NEEDS_GH_AUTH, FORGE_CREDENTIAL, TANGLE, STARTUP_MEMORY_BUDGET, CREW_DISPATCH invalid, FLEET_SYNC, NETWORK_CHECKS, PR_CHECK_MIGRATION, SECONDMATE_SYNC, SECONDMATE_LIVENESS, SECONDMATE_HANDOFF, NUDGE_SECONDMATES, or FMX - or when a standalone bin/fm-bootstrap.sh or bin/fm-startup-network.sh run prints one of those lines. A silent bootstrap section, or a BOOTSTRAP_INFO fact, means no skill load. user-invocable: false metadata: @@ -26,6 +26,18 @@ When any diagnostic needs captain attention, report the plain consequence and re - `BACKEND_INVALID: (known: )` - the resolved runtime backend has no verified dependency or lifecycle contract, so do not dispatch work until the invalid `FM_BACKEND` or `config/backend` value is corrected to one of the listed backends. - `NEEDS_GH_AUTH` - ask the captain to run `! gh auth login` (interactive; you cannot run it for them). This probe now arrives from the deferred network stage, so it is also how an unreachable network shows up: `gh` cannot validate its token offline and reports the same failure. Confirm reachability before asking the captain to re-authenticate a credential that may be fine. +- `FORGE_CREDENTIAL: : ` - this home tracks a repository on a forge whose credential firstmate holds itself, and that credential cannot be used, so merge detection and build results for every repository on that forge are unavailable until it is fixed. + The reason names the failing requirement only, never a credential value, and never quote a credential back to the captain or ask them to paste one into chat. + An absent or empty entry means the captain must create or re-cache it; a rejected credential means it was revoked, expired, or was created without the read scopes firstmate needs; a store read that did not answer in time means the stored item is raising a confirmation dialog no unattended session can answer, so it must be re-cached to allow an unattended read; a store that refused the read instead of reporting the entry missing means that same item is present but unreadable unattended, so it takes the same re-cache and never a hunt for a missing entry. + "cannot see repository " means the credential authenticated and the forge then refused to admit that repository exists, which does NOT say whose fault it is. + Scope refusal would have arrived as its own HTTP 403 line, so the live possibilities are a credential bound to the wrong account, a credential that has lost access to that specific private repository, or a repository that was renamed or moved. + Check those in that order: confirm which account the keychain entry belongs to, then confirm the captain still has access to the named repository, then confirm the repository still lives at the path the clone's origin remote gives. + "no credential store on this platform" is news, not a fault: this machine has no login keychain to read, that forge's merge and build checks are simply unavailable here, and there is nothing for the captain to retry. + Both of those two are reported once per home and then stay silent, so report each plainly and move on, and do not treat a later silence as the problem having been fixed. + The not-visible record is kept per probed repository, so a line naming a different repository later is fresh news reported in its own right rather than a repeat, while the no-store record is per forge because it names no repository. + A lock-refused session reports them without recording them, so the same line arriving again in the session that holds the lock is the expected handover, not a regression. + `bin/fm-forge-credential.sh`'s header owns the entry names, the required scopes, and the exit-code contract - read it before advising the captain, and tell them the consequence and the action rather than the diagnostic label. + Firstmate cannot create or store the credential itself, so this always ends in a captain action; work that does not touch that forge continues normally. - `NETWORK_CHECKS: ; rerun ` - the deferred network stage itself could not finish, so the checks it names are simply unknown, not failed. Rerun the printed command; it is idempotent and re-derives every finding. A `hit the ...s bound` line means one of those checks is slow or unreachable - most often a remote secondmate host - and the stage stopped rather than letting it wedge; a `lock was no longer held` line means the session that asked for the sweeps no longer owns them, so leave them to the session that does. diff --git a/AGENTS.md b/AGENTS.md index 334ff6b8ee..df63fc36e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -150,7 +150,7 @@ If the session lock cannot be acquired and verified, report its exact diagnostic A lock-refused session must not spawn, steer, merge, drain the wake queue, repair supervision, repair a checkout, or perform any other fleet mutation. The digest itself makes no external-network call and never waits for one. -Every network check a session start owes - GitHub auth, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, and project clone refresh - runs concurrently in a bounded worker owned by `bin/fm-startup-network.sh` and is reported in the digest's own `NETWORK CHECKS` section. +Every network check a session start owes - GitHub auth, the forge-credential check, dead-secondmate relaunch, secondmate convergence, pending handoff delivery, and project clone refresh - runs concurrently in a bounded worker owned by `bin/fm-startup-network.sh` and is reported in the digest's own `NETWORK CHECKS` section. When that section reports its checks still in progress it names exactly what is unconfirmed; treat none of those as passed until the result lands, either from `bin/fm-startup-network.sh report` or as a `check: startup-network` wake. 1. **Lock** - acquires the per-home session lock first, before anything mutates shared state, then starts the deferred network stage above. @@ -520,7 +520,7 @@ It performs guarded fast-forward updates of firstmate and registered secondmate These skills are not captain-invocable; load them only at their precise triggers. -- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `NETWORK_CHECKS:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `SECONDMATE_HANDOFF:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. +- `bootstrap-diagnostics` - load whenever the session-start digest's bootstrap or network-checks section prints an actionable diagnostic line (`MISSING:`, `MISSING_MANUAL:`, `BACKEND_INVALID:`, `NEEDS_GH_AUTH`, `FORGE_CREDENTIAL:`, `TANGLE:`, `STARTUP_MEMORY_BUDGET:`, `CREW_DISPATCH: invalid`, `FLEET_SYNC:`, `NETWORK_CHECKS:`, `PR_CHECK_MIGRATION:`, `SECONDMATE_SYNC:`, `SECONDMATE_LIVENESS:`, `SECONDMATE_HANDOFF:`, `NUDGE_SECONDMATES:`, or `FMX:`); silence and `BOOTSTRAP_INFO:` need no load. - `diagnostic-reasoning` - load before scoping a reported bug and before acting on a diagnostic report. - `ask-user-authority` - load before deciding any ask-user finding, regardless of the project's `yolo` posture. - `quota-array-dispatch` - load before choosing among a matched crew-dispatch profile array from current quota-axi output. diff --git a/README.md b/README.md index 8ed5226b17..fad31a2cae 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,7 @@ Firstmate's skills live in two separate places with different audiences: ## Documentation - [docs/architecture.md](docs/architecture.md) - maintainer architecture for the crew, supervision, worktrees, secondmates, and project modes. -- [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend selection, optional Relay and its X and Discord setup steps, the files you set, and harness support. +- [docs/configuration.md](docs/configuration.md) - environment variables, `FM_HOME`, runtime backend selection, forge credentials firstmate holds itself, optional Relay and its X and Discord setup steps, the files you set, and harness support. - [docs/remote-secondmates.md](docs/remote-secondmates.md) - current setup, routing, transfer, recovery, and safety behavior for whole-home remote second mates. - [docs/calm.md](docs/calm.md) - current Pi `/calm` behavior and supported presentation limits. - [docs/wedge-alarm.md](docs/wedge-alarm.md) - configure the active alert for an away-mode escalation delivery that gets stuck. diff --git a/bin/fm-bootstrap.sh b/bin/fm-bootstrap.sh index 47203fc17b..5c971943f1 100755 --- a/bin/fm-bootstrap.sh +++ b/bin/fm-bootstrap.sh @@ -7,6 +7,7 @@ # Silent = all good. # Lines: "MISSING: (install: )", # "MISSING_MANUAL: (instructions: )", "NEEDS_GH_AUTH", +# "FORGE_CREDENTIAL: : ", # "BACKEND_INVALID: (known: )", # "STARTUP_MEMORY_BUDGET: invalid config/startup-memory-budget - ", # "CREW_DISPATCH: invalid config/crew-dispatch.json - ", @@ -45,6 +46,27 @@ # failed names whether the endpoint was missing or agent-less. # Already-live and successfully relaunched secondmates are silent # unless FM_BOOTSTRAP_VERBOSE_FACTS=1 requests BOOTSTRAP_INFO facts. +# A FORGE_CREDENTIAL line means this home tracks a repository on a forge +# whose credential firstmate holds itself (today only Bitbucket), and +# that credential is missing, unusable, or refused - so merge and build +# checks for it cannot work. It is detect-only and probes exactly ONE +# deterministically chosen tracked repository per forge, so it costs at +# most one bounded request per session start however many clones on that +# forge the home tracks. It stays silent when no such repository is +# tracked and when the forge could not be reached. +# bin/fm-forge-credential.sh owns the resolution, the reason wording, +# the exit-code contract, and which forges firstmate holds a credential +# for; this check names no forge of its own. +# Two outcomes are reported ONCE per home and then stay silent, because +# each is news the first time and unactionable noise every session +# after: a machine with no credential store at all, and a repository +# the credential authenticated against but cannot see. The record is +# state/forge-credential-., keyed per home and per forge +# so the two outcomes cannot suppress each other; the not-visible record +# also keys on the probed repository, so each distinct unseen repository +# is worth one report, while the no-store record stays per forge. +# Under FM_BOOTSTRAP_DETECT_ONLY those records are NOT written: a +# lock-refused session reports the news without consuming it. # A TANGLE line means the firstmate primary checkout (FM_ROOT) is stranded # on a feature branch instead of its default branch - a crewmate's work # landed in the primary instead of its own worktree; restore it per the line. @@ -750,6 +772,125 @@ secondmate_handoff_detect() { done } +# Forge-credential detection. A missing, empty, or rejected credential used to +# be invisible until a PR step failed an hour into finished work; this moves +# that discovery to session start. It runs ONLY when this home actually tracks a +# repository on a forge whose credential firstmate holds itself, so a +# GitHub-only home never sees a line (gh owns that credential, per +# bin/fm-forge-credential.sh). Exactly ONE tracked repository is probed per +# forge per session start, so this costs at most one bounded request however +# many clones on that forge the home tracks. +# One probe is enough, and which clone it lands on cannot change what is +# reported, because of what the forge answers. Verified live on 21/07/2026 +# against api.bitbucket.org, every case with a fully resolved pair: an INVALID +# credential against a real private repository answers HTTP 401; a credential +# whose scopes do not cover the request answers HTTP 403 with a body naming the +# required and granted scopes, NOT 404; and a VALID credential against a +# nonexistent repository answers HTTP 404. So 401 and 403 are credential-level +# verdicts true of whichever repository was probed, and scope refusal announces +# itself rather than hiding as a 404. The clone is still chosen +# deterministically. +# What a 404 does NOT settle is whose fault it is: a credential holding +# repository read but bound to the wrong account, or one that has lost access to +# that specific private repository, is indistinguishable from a repository that +# was renamed or moved. Silencing it would put a genuinely broken credential +# back where this whole check exists to stop it being - invisible until a +# pull-request step fails - so it is REPORTED, once per home. +# Two outcomes are reported once per home and then stay silent, because each is +# news the first time and unactionable wallpaper every time after: a machine +# with no credential store at all, and a repository the credential cannot see. +# state/forge-credential-. is that record, keyed per home and +# per forge so the two outcomes cannot suppress each other. The not-visible +# outcome additionally keys on the probed repository, because its line names a +# repository and a later 404 on a different one is fresh news; the no-store +# outcome names no repository and stays keyed per forge. No record holds a +# credential value. + +# Returns 0 when this home has already been told this piece of news, 1 when it +# has not - and marks it told in the same step, so the line is printed exactly +# once. A record that cannot be written reports again rather than losing it. +forge_news_already_reported() { # + local marker="$STATE/forge-credential-$2.$1" + [ -e "$marker" ] && return 0 + # A session that did not get the fleet lock stays strictly read-only, so it + # reports the news without recording it: recording here would consume the one + # report and leave the session that CAN act about it silent. + [ "${FM_BOOTSTRAP_DETECT_ONLY:-0}" = 1 ] && return 1 + mkdir -p "$STATE" 2>/dev/null || return 1 + : > "$marker" 2>/dev/null || return 1 + return 1 +} + +forge_credential_report() { # [] + local forge=$1 status=$2 reason=$3 repo=${4:-} repo_key + case "$status" in + 0|7) return 0 ;; + 6) + forge_news_already_reported "$forge" no-store && return 0 + echo "FORGE_CREDENTIAL: $forge: no credential store on this platform, so $forge merge and build checks are unavailable here" + return 0 + ;; + 8) + # The line names one repository, so the record must too: a later 404 on a + # DIFFERENT repository is genuinely new news. The identifier is validated + # to [A-Za-z0-9._/-] with a single slash, so mapping '/' to '%' (a char + # the identifier cannot contain) is a collision-free, filesystem-safe key. + repo_key=${repo//\//%} + forge_news_already_reported "$forge" "not-visible.$repo_key" && return 0 + ;; + esac + reason=$(first_line "${reason#error: }") + [ -n "$reason" ] || reason="credential check failed (exit $status)" + echo "FORGE_CREDENTIAL: $forge: $reason" +} + +forge_credential_check() { + local resolver proj url urls out status target probe_forge probe_repo + resolver="$SCRIPT_DIR/fm-forge-credential.sh" + [ -x "$resolver" ] || return 0 + [ -d "$PROJECTS" ] || return 0 + urls= + for proj in "$PROJECTS"/*; do + [ -d "$proj" ] || continue + url=$(git -C "$proj" remote get-url origin 2>/dev/null) || continue + [ -n "$url" ] || continue + urls="$urls$url +" + done + [ -n "$urls" ] || return 0 + # Collect the remotes first, then ask the resolver once. Two properties this + # buys are worth the extra variable. Which forges firstmate holds a credential + # for is never restated here, so a forge added to the resolver's table cannot + # be silently skipped by this scan. And the scan turns a home's clone count + # into neither a session-start request count nor a session-start process + # count: one resolver process chooses the target, one proves it. + # The remotes are handed over in glob order, so which clone gets probed stays + # deterministic. + target=$(printf '%s' "$urls" | "$resolver" probe-target 2>/dev/null) || return 0 + probe_forge=${target%% *} + probe_repo= + case "$target" in + *' '*) probe_repo=${target#* } ;; + esac + [ -n "$probe_forge" ] || return 0 + if [ -n "$probe_repo" ]; then + if ! command -v curl >/dev/null 2>&1; then + report_missing_tool curl + return 0 + fi + out=$("$resolver" check "$probe_forge" "$probe_repo" 2>&1 >/dev/null) + status=$? + forge_credential_report "$probe_forge" "$status" "$out" "$probe_repo" + return 0 + fi + # Every tracked clone on that forge has an unusable remote: fall back to the + # local proof, which still catches a missing or empty credential and needs no + # request at all. + out=$("$resolver" check "$probe_forge" 2>&1 >/dev/null) + status=$? + forge_credential_report "$probe_forge" "$status" "$out" +} + install_cmd() { case "$1" in tmux|node|git|gh|curl|jq|orca|zellij) echo "brew install $1 # or the platform's package manager" ;; @@ -779,6 +920,19 @@ missing_tool_diagnostic() { echo "MISSING: $tool (install: $(install_cmd "$tool"))" } +# Several independent checks require the same tool - curl is required by both +# the forge-credential check and the X-mode relay poll - and the digest is +# parsed line by line, so a tool is named at most once however many checks want +# it. Every missing-tool report goes through here rather than echoing directly. +MISSING_TOOLS_REPORTED= +report_missing_tool() { # + case " $MISSING_TOOLS_REPORTED " in + *" $1 "*) return 0 ;; + esac + MISSING_TOOLS_REPORTED="$MISSING_TOOLS_REPORTED $1" + missing_tool_diagnostic "$1" +} + # Required-tool detection follows the RESOLVED backend, not a one-size default: # a universal toolchain every home needs plus the backend-specific delta owned by # fm_backend_required_tools (bin/fm-backend.sh). So a herdr/zellij/cmux home is @@ -937,7 +1091,7 @@ x_mode_setup() { missing=0 for tool in curl jq; do if ! command -v "$tool" >/dev/null 2>&1; then - echo "MISSING: $tool (install: $(install_cmd "$tool"))" + report_missing_tool "$tool" missing=1 fi done @@ -992,7 +1146,7 @@ crew_dispatch_validate() { file="$CONFIG/crew-dispatch.json" [ -f "$file" ] || return 0 if ! command -v jq >/dev/null 2>&1; then - echo "MISSING: jq (install: $(install_cmd jq))" + report_missing_tool jq return 0 fi if ! jq -e . "$file" >/dev/null 2>&1; then @@ -1210,6 +1364,13 @@ if network_phase; then __fm_timing_stamp=$(fm_timing_now_ms) gh auth status >/dev/null 2>&1 || echo "NEEDS_GH_AUTH" fm_timing_record phase gh-auth "$__fm_timing_stamp" + # The forge-credential probe is the same shape as the gh-auth check above - one + # bounded request that answers "can firstmate still act on this forge" - so it + # belongs in the deferred network phase beside it, never on the blocking local + # pass that composes the digest. + __fm_timing_stamp=$(fm_timing_now_ms) + forge_credential_check + fm_timing_record phase forge-credential "$__fm_timing_stamp" fi local_phase && detect_local_config diff --git a/bin/fm-forge-credential.sh b/bin/fm-forge-credential.sh new file mode 100755 index 0000000000..4b6aa87b8b --- /dev/null +++ b/bin/fm-forge-credential.sh @@ -0,0 +1,687 @@ +#!/usr/bin/env bash +# Resolve firstmate's own read-only forge credential and make read-only forge +# API calls with it. +# +# Usage: fm-forge-credential.sh check [] +# fm-forge-credential.sh api-get +# fm-forge-credential.sh forge-of +# fm-forge-credential.sh repo-of +# fm-forge-credential.sh probe-target < +# +# check Resolve the credential, and with also prove it can read +# that repository. Silent and exit 0 when it works; one reason line on +# stderr and a classifying exit code otherwise. With no +# the proof is local only: a store read, no network, so an expired or +# revoked credential still passes. +# is the forge's own identifier, "/" on +# Bitbucket. Bitbucket has no workspace-agnostic probe left to use +# instead: its account-wide listing endpoints were withdrawn under +# CHANGE-2770 and now answer HTTP 410 to everyone. +# api-get Perform ONE authenticated read-only GET of against the +# forge's API host and print the response body on stdout. +# is a path, never a URL: the host is fixed per forge here, so no +# caller can point the credential at another host, and redirects are +# never followed. +# forge-of Print the forge name for a git remote or PR URL (single owner of +# that mapping), or exit 1 when the host belongs to no known forge. +# repo-of Print the repository identifier that same URL names, in the forge's +# own form, or exit 1 when the URL names no usable repository. It +# reads a repository URL, such as an origin remote, and deliberately +# not a pull-request URL: PR-URL grammar already has an owner in +# bin/fm-teardown.sh's pr_number_from_target. +# probe-target +# Read git remote URLs on stdin, one per line, and print the ONE +# repository worth probing to prove a credential: +# " " for the first URL on a forge whose credential +# firstmate holds that also names a usable repository, or "" +# alone when such a forge is tracked but no remote on it names one. +# Exit 1 when no URL is on a forge firstmate holds a credential for. +# It exists so a caller scanning a home's clones needs neither the +# forge table nor one process per clone: which forges firstmate holds +# a credential for stays owned here, and the scan costs one process +# however many clones the home tracks. +# +# Exit codes - the contract every caller reads: +# 0 ok +# 2 usage error, including a forge with no firstmate-held credential +# 3 credential absent - no such entry in the login keychain +# 4 credential incomplete - an entry exists but is empty or unusable +# 5 credential rejected by the forge - invalid, revoked, expired, or under-scoped +# 6 no credential store available on this machine +# 7 inconclusive - the forge could not be reached or answered nothing usable +# (a transport error, a 5xx, or a rate-limit refusal), so nothing was proved +# 8 the credential authenticated but the requested repository or resource is +# not visible to it; a 404 alone does not settle whether the account or +# scopes are wrong or the repository moved +# 9 unexpected forge response +# 10 the credential store did not answer within the allowed wait +# 11 the credential store refused the read - the entry was not reported +# missing, the store declined to hand it over, as it does when the stored +# item's access control forbids an unattended read or the keychain is +# locked +# +# BOUNDED WAITS. Neither step of the path may stall a session start. The request +# is bounded by FM_FORGE_CREDENTIAL_TIMEOUT (default 10 seconds) and the store +# read by FM_FORGE_KEYCHAIN_TIMEOUT (default 5 seconds); a blank, non-numeric, or +# zero value falls back to the default, because zero means "no limit" to curl +# rather than "do not wait". The store read needs a watchdog of its own because +# `security` has no timeout flag: it blocks indefinitely when the stored item's +# access control makes the read raise a confirmation dialog, which an unattended +# session can never answer. That stall is its own outcome (exit 10) rather than +# a silent pass, and its remedy is to re-cache the item so an unattended read is +# allowed. The same access control can also make the store refuse the read +# outright instead of blocking - it answers at once, so no watchdog ever fires - +# and that answer is exit 11 rather than "the entry is absent", because sending +# the captain to look for an entry that is present and merely unreadable is the +# opposite of the remedy, which is again to re-cache it. +# +# WHY IT READS THE KEYCHAIN DIRECTLY. The credential must resolve identically +# whether firstmate was started from a warm interactive terminal, re-armed by a +# background repair path, or resumed after a reboot. Inheriting an exported +# token from a shell profile is exactly the fragility that leaves a restarted +# daemon tokenless, so this script reads the store itself: it never sources a +# profile, never prompts, and never takes a secret from the environment. +# +# WHAT IT WILL NOT TOUCH. For Bitbucket, firstmate holds its OWN read-only +# Atlassian account API token under the keychain services +# firstmate-bitbucket-email and firstmate-bitbucket-token, used with HTTP Basic +# (email as username, token as password). no-mistakes' separate write-capable +# credential is deliberately out of reach - this script must never read it - +# because keeping an unattended reader write-incapable is the point of the +# design, not a detail. For GitHub, firstmate holds no credential at all; the +# gh CLI owns it. +# +# SECRET HANDLING. The resolved pair never reaches stdout, stderr, a log, argv, +# or a file. It leaves the store through a private FIFO, which carries it in +# memory and stores nothing on disk, and it is handed to curl through a config +# on stdin (curl --config -), so it stays out of ps and shell history. Every +# curl diagnostic is discarded, so no reason line can come from anywhere but +# this script itself. Diagnostics name the failing +# REQUIREMENT - which entry, which HTTP status - and never the value. A +# half-resolved pair is never published: both halves must validate before +# either becomes usable. +# +# Merge detection and build status are callers of `api-get` and `forge-of`; +# they are deliberately not implemented here. +set -u + +EX_OK=0 +EX_USAGE=2 +EX_ABSENT=3 +EX_INCOMPLETE=4 +EX_REJECTED=5 +EX_NO_STORE=6 +EX_INCONCLUSIVE=7 +EX_NOT_FOUND=8 +EX_UNEXPECTED=9 +EX_STORE_TIMEOUT=10 +EX_STORE_REFUSED=11 + +# What the store reports for "no such item", as distinct from every other way a +# read can fail. macOS `security` exits 44 (errSecItemNotFound) for a missing +# entry and answers other refusals with their own status - 51 when the item's +# access control forbids an unattended read, 25293 when authentication failed - +# so only 44 may be reported as an absent entry. +STORE_NOT_FOUND_STATUS=44 + +KEYCHAIN_TOOL="${FM_FORGE_KEYCHAIN_TOOL_OVERRIDE:-/usr/bin/security}" + +# A bound of zero is not "do not wait": curl reads --max-time 0 as no limit at +# all, so zero is refused alongside blank and non-numeric rather than silently +# removing the bound both this header and docs/configuration.md promise. +positive_seconds() { # + case "$1" in + ''|*[!0-9]*) printf '%s' "$2"; return 0 ;; + esac + if [ "$1" -gt 0 ] 2>/dev/null; then + printf '%s' "$1" + else + printf '%s' "$2" + fi +} + +REQUEST_TIMEOUT=$(positive_seconds "${FM_FORGE_CREDENTIAL_TIMEOUT:-}" 10) +STORE_TIMEOUT=$(positive_seconds "${FM_FORGE_KEYCHAIN_TIMEOUT:-}" 5) + +NL=' +' +CR=$'\r' + +# Set by resolve_credential on success, cleared by every failure path so a +# partial pair can never reach a request. +CRED_USER= +CRED_SECRET= +# One value-free reason for the last failure. +REASON= +# Out-parameter for read_keychain_half, so its diagnostics survive: a command +# substitution would run the reader in a subshell and lose REASON with it. +KEYCHAIN_VALUE= +# Out-parameters for read_store_value, for the same reason: the first line the +# store returned, whether anything followed it, and the store command's own exit +# status. +STORE_LINE= +STORE_TRAILING=0 +STORE_STATUS=0 + +usage() { + cat <<'EOF' +usage: fm-forge-credential.sh check [] + fm-forge-credential.sh api-get + fm-forge-credential.sh forge-of + fm-forge-credential.sh repo-of + fm-forge-credential.sh probe-target < + +Read this script's header for the forge table, the exit-code contract, and the +secret-handling rules every caller inherits. +EOF +} + +# --- forge table ------------------------------------------------------------- + +forge_api_base() { + case "$1" in + bitbucket) printf '%s' 'https://api.bitbucket.org' ;; + *) return 1 ;; + esac +} + +# The probe that proves a credential actually authenticates. Reading ONE named +# repository is what is left to probe with: it stays inside firstmate's +# read-only scopes, and on a private repository it cannot be satisfied +# anonymously, so a silently unauthenticated request can never pass it. +# Bitbucket's /2.0/user is unusable here because it needs read:user, which +# firstmate's token deliberately does not carry, and the account-wide listings +# were withdrawn under CHANGE-2770 (HTTP 410, verified 21/07/2026). +forge_repo_valid() { # + case "$1" in + bitbucket|github) ;; + *) return 1 ;; + esac + case "$2" in + */*/*|/*|*/) return 1 ;; + */*) ;; + *) return 1 ;; + esac + case "$2" in + *[!A-Za-z0-9._/-]*|*..*|.*|*/.) return 1 ;; + esac +} + +forge_repo_path() { # + forge_repo_valid "$1" "$2" || return 1 + case "$1" in + bitbucket) printf '%s' "/2.0/repositories/$2" ;; + *) return 1 ;; + esac +} + +forge_keychain_service() { # + case "$1/$2" in + bitbucket/user) printf '%s' 'firstmate-bitbucket-email' ;; + bitbucket/secret) printf '%s' 'firstmate-bitbucket-token' ;; + *) return 1 ;; + esac +} + +# The single owner of "which forges does firstmate hold a credential for". Every +# other answer in this script, and every caller's, derives from it, so adding a +# forge here is the whole change rather than the first half of one. +forge_holds_credential() { # + case "$1" in + bitbucket) return 0 ;; + *) return 1 ;; + esac +} + +# Refuse an unsupported forge before anything reads a store or the network. +forge_supported() { + forge_holds_credential "$1" && return 0 + case "$1" in + github) + REASON="github has no firstmate-held credential; GitHub access is owned by the gh CLI" + return 1 + ;; + *) + REASON="unknown forge" + return 1 + ;; + esac +} + +# --- credential resolution --------------------------------------------------- + +# Run the store command under a watchdog and report what it said, bounded by +# STORE_TIMEOUT. The value travels through a private FIFO rather than a +# temporary file, so it is never written to disk; the reader stops waiting at +# the bound and kills the store command, so an item whose access control raises +# a confirmation dialog cannot hold a session start open forever. +# The first line lands in STORE_LINE and anything after it sets STORE_TRAILING, +# because a value carrying a line break must still be detectable as one. Both +# come out of a SINGLE read of everything the store wrote: the reader may never +# go back to the pipe for a second helping, because a pipe with no writer left +# does not answer that second read on macOS until the backstop expires, and a +# reader that waits that long turns every silent answer into a false stall. +read_store_value() { # + local service=$1 dir fifo expired store_pid watchdog_pid backstop raw + STORE_LINE= + STORE_TRAILING=0 + STORE_STATUS=0 + dir=$(umask 077; mktemp -d "${TMPDIR:-/tmp}/fm-forge-store.XXXXXX" 2>/dev/null) || { + REASON="could not create a private channel to read keychain entry $service" + return "$EX_INCONCLUSIVE" + } + fifo="$dir/pipe" + expired="$dir/expired" + if ! mkfifo "$fifo" 2>/dev/null; then + rm -rf -- "$dir" + REASON="could not create a private channel to read keychain entry $service" + return "$EX_INCONCLUSIVE" + fi + "$KEYCHAIN_TOOL" find-generic-password -s "$service" -w >"$fifo" 2>/dev/null & + store_pid=$! + # The watchdog is what bounds this read, and the marker it leaves behind is + # what identifies the outcome: stock macOS bash 3.2 reports a `read` timeout + # with the same status as end of input, so the status alone cannot tell "never + # answered" from "answered nothing". Killing the store command also ends the + # read, because that closes the last writer on the pipe. + # The marker is written BEFORE the kill, never after: the kill is what releases + # the main shell's wait, so writing the marker second would race it, and losing + # that race would leave a stalled read wearing the killed command's exit status + # and be classified as an absent entry - the exact conflation this outcome + # exists to prevent. + # Every inherited stream is closed first: the watchdog outlives the read by + # design, and a caller reading this script through a command substitution + # would otherwise wait for the watchdog's own sleep to end before seeing the + # answer - reintroducing the stall from the other side. + ( sleep "$STORE_TIMEOUT"; : > "$expired"; kill "$store_pid" 2>/dev/null ) \ + /dev/null 2>&1 & + watchdog_pid=$! + # A backstop for the case where something other than the store command still + # holds the pipe open after it is gone; the watchdog remains the real bound. + backstop=$((STORE_TIMEOUT + 2)) + exec 3<"$fifo" + # ONE read, never one per line. A second read on a pipe whose last writer has + # already gone waits out the whole backstop on macOS instead of seeing end of + # input, so reading the first line and then reading again to detect trailing + # data made every entry the store answers nothing for - an absent one, above + # all - arrive as a five-second stall wearing the stall's remedy. Reading to + # NUL takes everything the store wrote in a single bounded call, after which + # the first line and whether anything followed it are string questions. + raw= + IFS= read -r -d '' -t "$backstop" -u 3 raw + exec 3<&- + STORE_LINE=${raw%%"$NL"*} + # One trailing line terminator is the store's own, not trailing data. Anything + # still there after removing it is a value carrying a line break, which is + # detected and refused rather than repaired. + raw=${raw%"$NL"} + case "$raw" in + *"$NL"*) STORE_TRAILING=1 ;; + esac + raw= + # Always before the watchdog is stood down, so this wait is bounded by it. + wait "$store_pid" 2>/dev/null + STORE_STATUS=$? + kill "$watchdog_pid" 2>/dev/null + wait "$watchdog_pid" 2>/dev/null + if [ -e "$expired" ]; then + rm -rf -- "$dir" + STORE_LINE= + STORE_TRAILING=0 + STORE_STATUS=0 + REASON="keychain entry $service did not answer within ${STORE_TIMEOUT}s: the stored item is prompting instead of answering, so re-cache it to allow an unattended read" + return "$EX_STORE_TIMEOUT" + fi + rm -rf -- "$dir" + return "$EX_OK" +} + +# Read one half of the pair into KEYCHAIN_VALUE, or set REASON and return the +# classifying code. No failure path ever reveals the value. +read_keychain_half() { # + local forge=$1 half=$2 service value status + KEYCHAIN_VALUE= + service=$(forge_keychain_service "$forge" "$half") || { + REASON="no keychain entry is defined for $forge" + return "$EX_USAGE" + } + read_store_value "$service" || { status=$?; STORE_LINE=; return "$status"; } + value=$STORE_LINE + STORE_LINE= + if [ "$STORE_STATUS" -eq "$STORE_NOT_FOUND_STATUS" ]; then + REASON="keychain entry $service is absent from the login keychain" + return "$EX_ABSENT" + fi + if [ "$STORE_STATUS" -ne 0 ]; then + # Not "absent": the store answered, and what it answered was a refusal. + # Reporting it as a missing entry would send the captain looking for + # something that is there, instead of re-caching what will not open. + REASON="keychain entry $service could not be read: the store refused the read (status $STORE_STATUS) 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" + return "$EX_STORE_REFUSED" + fi + if [ "$STORE_TRAILING" -ne 0 ]; then + # A line break would end the curl config line and let the remainder act as + # further curl directives. + REASON="keychain entry $service contains a line break and cannot be used safely" + return "$EX_INCOMPLETE" + fi + if [ -z "$value" ]; then + REASON="keychain entry $service is present but empty" + return "$EX_INCOMPLETE" + fi + case "$value" in + *"$NL"*|*"$CR"*) + # A bare carriage return ends the curl config line just as a newline does, + # and never reaches the trailing-data check above. + REASON="keychain entry $service contains a line break and cannot be used safely" + return "$EX_INCOMPLETE" + ;; + esac + if [ "$half" = user ]; then + case "$value" in + *:*) + REASON="keychain entry $service is not a usable HTTP Basic username (it contains ':')" + return "$EX_INCOMPLETE" + ;; + esac + fi + KEYCHAIN_VALUE=$value + return "$EX_OK" +} + +# Resolve the complete pair, or refuse. Both halves must validate before either +# is published, so no caller is ever handed a partial credential. +resolve_credential() { # + local forge=$1 user status + CRED_USER= + CRED_SECRET= + if [ ! -x "$KEYCHAIN_TOOL" ]; then + REASON="no credential store on this machine: $KEYCHAIN_TOOL is not available" + return "$EX_NO_STORE" + fi + read_keychain_half "$forge" user || { status=$?; KEYCHAIN_VALUE=; return "$status"; } + user=$KEYCHAIN_VALUE + read_keychain_half "$forge" secret || { status=$?; KEYCHAIN_VALUE=; return "$status"; } + CRED_USER=$user + CRED_SECRET=$KEYCHAIN_VALUE + KEYCHAIN_VALUE= + return "$EX_OK" +} + +# --- request path ------------------------------------------------------------ + +# Escape a value for a curl config double-quoted string, where a backslash +# escapes itself and the quote. +escape_curl_config() { + local value=$1 + value=${value//\\/\\\\} + value=${value//\"/\\\"} + printf '%s' "$value" +} + +# One authenticated read-only GET. The body reaches stdout on success only; a +# reason plus a classifying exit code carries every failure. Redirects are never +# followed, so no forge response can forward the credential to another host. +forge_get() { # + local forge=$1 path=$2 base body http curl_status status + base=$(forge_api_base "$forge") || { + REASON="unknown forge" + return "$EX_USAGE" + } + case "$path" in + //*) + REASON="api path must not start with '//'" + return "$EX_USAGE" + ;; + /*) ;; + *) + REASON="api path must start with '/'" + return "$EX_USAGE" + ;; + esac + case "$path" in + *[[:space:]]*) + REASON="api path must not contain whitespace" + return "$EX_USAGE" + ;; + esac + if ! command -v curl >/dev/null 2>&1; then + REASON="curl is not installed, so the credential could not be verified" + return "$EX_INCONCLUSIVE" + fi + body=$(umask 077; mktemp "${TMPDIR:-/tmp}/fm-forge-body.XXXXXX" 2>/dev/null) || { + REASON="could not create a temporary file for the response" + return "$EX_INCONCLUSIVE" + } + http=$(printf 'user = "%s"\n' "$(escape_curl_config "$CRED_USER:$CRED_SECRET")" \ + | curl --silent --config - \ + --request GET \ + --header 'Accept: application/json' \ + --max-time "$REQUEST_TIMEOUT" \ + --output "$body" \ + --write-out '%{http_code}' \ + "$base$path" 2>/dev/null) + curl_status=$? + if [ "$curl_status" -ne 0 ]; then + rm -f -- "$body" + REASON="no usable response from ${base#https://} (curl exit $curl_status)" + return "$EX_INCONCLUSIVE" + fi + case "$http" in + 2??) + cat -- "$body" + rm -f -- "$body" + return "$EX_OK" + ;; + 401) + REASON="credential rejected by $forge (HTTP 401): the token is invalid, revoked, or expired" + status=$EX_REJECTED + ;; + 403) + REASON="credential refused by $forge (HTTP 403): it lacks the required read scopes" + status=$EX_REJECTED + ;; + 404) + REASON="$forge has no such resource (HTTP 404)" + status=$EX_NOT_FOUND + ;; + 000|'') + REASON="no usable response from ${base#https://}" + status=$EX_INCONCLUSIVE + ;; + 429|5??) + # The forge is unreachable in every sense that matters here: an incident + # or a rate limit says nothing about the credential, so it must not be + # reported as a credential fault or repeated at every session start. + REASON="${base#https://} could not answer the request (HTTP $http), so nothing was proved about the credential" + status=$EX_INCONCLUSIVE + ;; + *) + REASON="unexpected response from $forge (HTTP $http)" + status=$EX_UNEXPECTED + ;; + esac + rm -f -- "$body" + return "$status" +} + +# --- subcommands ------------------------------------------------------------- + +cmd_check() { # [] + local forge=$1 repo=${2:-} path status + forge_supported "$forge" || return "$EX_USAGE" + resolve_credential "$forge" || { status=$?; return "$status"; } + [ -n "$repo" ] || return "$EX_OK" + if ! path=$(forge_repo_path "$forge" "$repo"); then + REASON="'$repo' is not a valid $forge repository identifier" + return "$EX_USAGE" + fi + forge_get "$forge" "$path" >/dev/null || { + status=$? + case "$status" in + "$EX_NOT_FOUND") + REASON="the credential authenticated but cannot see $forge repository $repo (HTTP 404): its account or scopes may be wrong, or the repository moved" + ;; + esac + return "$status" + } + return "$EX_OK" +} + +cmd_api_get() { # + local forge=$1 path=$2 status + forge_supported "$forge" || return "$EX_USAGE" + resolve_credential "$forge" || { status=$?; return "$status"; } + forge_get "$forge" "$path" || { status=$?; return "$status"; } + return "$EX_OK" +} + +# Single owner of the url-to-forge mapping. The host is matched exactly, so a +# URL that merely mentions a forge host inside its path is never mistaken for +# one. +url_host() { # + local url=$1 host + case "$url" in + *://*) + host=${url#*://} + host=${host%%/*} + ;; + *:*) + host=${url%%:*} + ;; + *) return 1 ;; + esac + host=${host##*@} + host=${host%%:*} + [ -n "$host" ] || return 1 + printf '%s' "$host" | tr '[:upper:]' '[:lower:]' +} + +cmd_forge_of() { # + local host + host=$(url_host "$1") || return 1 + case "$host" in + bitbucket.org) printf '%s\n' bitbucket ;; + github.com) printf '%s\n' github ;; + *) return 1 ;; + esac +} + +url_path() { # + local url=$1 rest + case "$url" in + *://*) + rest=${url#*://} + case "$rest" in + */*) rest=${rest#*/} ;; + *) return 1 ;; + esac + ;; + *:*) rest=${url#*:} ;; + *) return 1 ;; + esac + rest=${rest#/} + rest=${rest%/} + rest=${rest%.git} + [ -n "$rest" ] || return 1 + printf '%s' "$rest" +} + +# The repository identifier a git remote or PR URL names, in the forge's own +# form. Same single-owner reason as forge-of: one place parses forge URLs. +cmd_repo_of() { # + local forge repo + forge=$(cmd_forge_of "$1") || return 1 + repo=$(url_path "$1") || return 1 + forge_repo_valid "$forge" "$repo" || return 1 + printf '%s\n' "$repo" +} + +# Choose the one repository worth probing out of a home's tracked remotes. The +# forge table stays owned here rather than being restated by the caller, and the +# whole scan costs the caller one process instead of one per clone. +# A remote that names a repository wins, because only a repository read proves +# the credential is still accepted; a forge with no such remote is still worth +# naming, because the local proof still catches a missing or empty credential. +# The first usable remote in the order given wins, so the caller's own ordering +# decides the target and the choice stays deterministic. +cmd_probe_target() { + local url forge repo fallback= + while IFS= read -r url; do + [ -n "$url" ] || continue + forge=$(cmd_forge_of "$url") || continue + forge_holds_credential "$forge" || continue + if repo=$(cmd_repo_of "$url"); then + printf '%s %s\n' "$forge" "$repo" + return 0 + fi + [ -n "$fallback" ] || fallback=$forge + done + [ -n "$fallback" ] || return 1 + printf '%s\n' "$fallback" +} + +# --- entry ------------------------------------------------------------------- + +STATUS=0 +COMMAND=${1:-} +case "$COMMAND" in + check) + shift + if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then + echo "usage: fm-forge-credential.sh check []" >&2 + exit "$EX_USAGE" + fi + cmd_check "$@" + STATUS=$? + ;; + api-get) + shift + if [ "$#" -ne 2 ]; then + echo "usage: fm-forge-credential.sh api-get " >&2 + exit "$EX_USAGE" + fi + cmd_api_get "$1" "$2" + STATUS=$? + ;; + forge-of) + shift + if [ "$#" -ne 1 ]; then + echo "usage: fm-forge-credential.sh forge-of " >&2 + exit "$EX_USAGE" + fi + cmd_forge_of "$1" + exit $? + ;; + repo-of) + shift + if [ "$#" -ne 1 ]; then + echo "usage: fm-forge-credential.sh repo-of " >&2 + exit "$EX_USAGE" + fi + cmd_repo_of "$1" + exit $? + ;; + probe-target) + shift + if [ "$#" -ne 0 ]; then + echo "usage: fm-forge-credential.sh probe-target < " >&2 + exit "$EX_USAGE" + fi + cmd_probe_target + exit $? + ;; + -h|--help|help) + usage + exit "$EX_OK" + ;; + *) + usage >&2 + exit "$EX_USAGE" + ;; +esac + +if [ "$STATUS" -ne "$EX_OK" ]; then + printf 'error: %s\n' "$REASON" >&2 +fi +exit "$STATUS" diff --git a/docs/configuration.md b/docs/configuration.md index e4aef1a7cc..72a40960c2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -305,6 +305,7 @@ Orca provides both the task worktree and terminal endpoint (see "Runtime backend A herdr, zellij, or cmux home is therefore never told `tmux` is missing, and the `treehouse` durable-lease upgrade check runs only for the backends that actually use treehouse. When `config/crew-dispatch.json` exists, bootstrap also requires `jq` for dispatch profile validation. When Relay is opted in, bootstrap also requires `curl` and `jq` before arming the relay poll shim. +When this home tracks a repository on a forge whose credential firstmate holds itself, bootstrap also requires `curl` for the credential check in "Forge credentials" below. `tasks-axi` and `quota-axi` are required bootstrap tools in every profile, the same class as `lavish-axi`. An absent or incompatible `tasks-axi` reports `MISSING: tasks-axi (install: npm install -g tasks-axi)`; when `config/backlog-backend` is not `manual` and compatible `tasks-axi` is on `PATH`, bootstrap stays silent and firstmate uses its verbs for routine backlog mutations, otherwise it hand-edits `data/backlog.md` until installation is approved and completed. An absent or incompatible `gh-axi` reports `MISSING: gh-axi (install: npm install -g gh-axi && gh-axi setup hooks)`. @@ -333,6 +334,68 @@ The locked bootstrap inheritance pass uses the same placement-specific behavior; That live discovery starts from `state/*.meta` records with `kind=secondmate`; `data/secondmates.md` only backfills `home=` for older or incomplete meta records. Skipped items, such as a destination checkout that does not yet gitignore the item, are visible warnings but not hard failures. +## Forge credentials (login keychain) + +Some forges have no credential-owning CLI for firstmate to shell out to, so firstmate holds a credential of its own for them. +GitHub is not one of them: `gh` owns that credential, firstmate has never held a GitHub token, and nothing here changes that. +Bitbucket Cloud is, and `bin/fm-forge-credential.sh` is the only thing that reads it; its header owns the entry names, subcommands, and exit-code contract, and every caller goes through it rather than reading a store directly. + +What the captain provisions for Bitbucket is an Atlassian **account API token for the account that can read the repositories in question**, cached as two login-keychain items: + +| Keychain service | Holds | +| --- | --- | +| `firstmate-bitbucket-email` | the account email, used as the HTTP Basic username | +| `firstmate-bitbucket-token` | the token, used as the HTTP Basic password | + +Both halves are required, because an account API token authenticates over HTTP Basic rather than as a bearer token. + +Three properties of this arrangement are deliberate and load-bearing. + +**It is read-only.** Its scopes are repository, pull request, and pipeline READ, nothing else, so an unattended reader cannot push, merge, or otherwise change a repository - it is write-incapable by construction rather than by convention. +The accepted consequence is that firstmate can detect merges and read build results but cannot merge on Bitbucket; granting that would require pull-request write, which is a separate captain decision. + +**It is separate from no-mistakes' credential.** no-mistakes keeps its own write-capable Bitbucket credential, cached under a different service name and populated from the interactive shell path, because it pushes branches and opens pull requests. +Firstmate must never read it. Keeping an unattended reader write-incapable is the entire point of holding two credentials instead of one. + +**It is read from the keychain directly, never from the environment.** The resolver reads the store itself, so it behaves identically whether firstmate was started from a warm interactive terminal, re-armed by a background repair path, or resumed after a reboot. +Inheriting an exported token from a shell profile is what leaves a restarted daemon silently tokenless, and firstmate does not reproduce it. + +Session start verifies the credential when, and only when, this home tracks a repository on such a forge, and reports `FORGE_CREDENTIAL: : ` when it is missing, empty, unusable, refused, or cannot see the repository it was probed against. +That check probes exactly one deterministically chosen tracked repository per forge, so it costs at most one bounded request per session start however many clones on that forge the home tracks. +It stays silent when the forge cannot be reached: being offline is not a credential fault, and neither is a forge incident or a rate-limit refusal, so a transport error, a 5xx, and an HTTP 429 all report nothing rather than blaming a credential that was never judged. +The whole reason it runs at startup is that a stale credential used to be invisible until a pull-request step failed roughly an hour into finished work. +No diagnostic anywhere in this path prints a credential value; each one names the failing requirement instead. + +One probe settles the credential for the whole forge because of what the forge answers, verified live on 21/07/2026 against `api.bitbucket.org` with a fully resolved pair in every case. +An invalid credential against a real private repository answers HTTP 401. +A credential whose scopes do not cover the request answers HTTP 403 with a body naming the required and granted scopes, not 404, so scope refusal announces itself rather than hiding as a missing repository. +A valid credential against a nonexistent repository answers HTTP 404. +The first two are credential-level verdicts true of whichever repository was probed, which is why which clone gets probed cannot change what is reported. + +Two of that check's reporting choices are deliberate, and both follow from the same rule: a startup line the captain cannot act on trains them to skim past startup lines, but a line withheld entirely puts the discovery back at the failed pull-request step this check exists to pre-empt. +Each is therefore reported **once per home**, then silence, recorded in `state/forge-credential-.`. +That record is keyed per home and per forge, so the two outcomes cannot suppress each other, and it holds no content of any kind, least of all a credential value. +The not-visible record additionally keys on the probed repository, so each distinct unseen repository is worth one report and a later 404 on a different repository is not silenced by an earlier one; the no-store record names no repository and stays keyed per forge. +Deleting it makes the next session start say it again. +A session that did not get the fleet lock reports the news but does not write the record, so the session that can actually act on it is the one that consumes it. + +The first is a machine with no credential store at all - today anything other than macOS, since the store is the login keychain. +The line says the forge's merge and build checks are unavailable here rather than implying a fault the captain can fix by retrying. + +The second is a repository the credential authenticated against but cannot see. +A 404 does not settle whose fault it is: a credential holding repository read but bound to the wrong account, or one that has lost access to that specific private repository, is indistinguishable from a repository that was renamed or moved. +The line names the repository that was probed and both possibilities, so the captain checks the credential's account and scopes as well as the repository's location. +Silencing it was rejected because a credential broken in that way would otherwise stay invisible, which is exactly the failure this task removes. + +Both waits on this path are bounded, because a startup check that can hang session start is worse than the late failure it replaces. +`FM_FORGE_CREDENTIAL_TIMEOUT` bounds the forge request and `FM_FORGE_KEYCHAIN_TIMEOUT` bounds the store read; a blank, non-numeric, or zero value falls back to the default, since zero means "no limit" to curl rather than "do not wait". +The store read needs its own bound because `security` has no timeout flag and blocks indefinitely when the stored item's access control makes the read raise a confirmation dialog that an unattended session can never answer. +That stall is reported every time it happens, distinctly from every other outcome, because it is actionable: re-cache the item so an unattended read is allowed. +The same access control can also make the store refuse the read outright instead of blocking, and that refusal is its own outcome too, never reported as an absent entry: the entry is present and merely unreadable unattended, so sending the captain to look for a missing one would point them away from the same re-cache that fixes it. + +Verified 21/07/2026 against the live Bitbucket Cloud API with `bin/fm-forge-credential.sh check bitbucket /`: a valid read-only credential on a private repository returns HTTP 200 and exit 0, the same request with an invalid token returns HTTP 401 and exit 5, a credential whose scopes do not cover the request returns HTTP 403 and exit 5 with a body naming the required and granted scopes, and an unknown repository returns HTTP 404 and exit 8. +The account-wide listing endpoints that would otherwise make a workspace-agnostic probe possible - `/2.0/repositories`, `/2.0/workspaces`, `/2.0/user/permissions/repositories` - all answer HTTP 410 with `CHANGE-2770 - Functionality has been deprecated`, authenticated or not, which is why the verification probe reads one named repository instead. + ## Relay (.env) Relay lets a firstmate instance answer public mentions and act on normal reversible mention requests through firstmate's normal lifecycle. @@ -525,6 +588,9 @@ FM_DATA_OVERRIDE= # alternate data dir, mainly for tests FM_PROJECTS_OVERRIDE= # alternate projects dir, mainly for tests FM_CONFIG_OVERRIDE= # alternate config dir, mainly for tests FM_PROC_ROOT_OVERRIDE= # alternate /proc root for Linux process-identity reads in fm-wake-lib.sh and fm-teardown.sh, mainly for tests +FM_FORGE_KEYCHAIN_TOOL_OVERRIDE=/usr/bin/security # credential-store reader used by fm-forge-credential.sh, mainly for tests +FM_FORGE_CREDENTIAL_TIMEOUT=10 # seconds allowed for one forge API request; a blank, non-numeric, or zero value uses 10 +FM_FORGE_KEYCHAIN_TIMEOUT=5 # seconds allowed for one credential-store read; a blank, non-numeric, or zero value uses 5 FM_BACKEND= # optional runtime backend override for new spawns; tmux/herdr/zellij/orca/cmux support ship/scout spawns, codex-app is not accepted FM_TRACE_CONTEXT= # optional trace-context override; see "Trace context propagation" HERDR_SESSION=default # herdr-only: named session for normal backend ops; not enough for destructive cleanup (docs/herdr-backend.md) diff --git a/docs/scripts.md b/docs/scripts.md index e94ccb0e16..a121b10094 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -101,6 +101,7 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize | `fm-peek.sh` | Print a bounded tail of a crewmate endpoint | | `fm-check-register.sh` | Bind an intentional custom watcher check to its current bytes | | `fm-check-lib.sh` | Validate custom-check registrations and prepare private execution snapshots | +| `fm-forge-credential.sh` | Resolve firstmate's own read-only forge credential from the login keychain and make read-only forge API calls with it | | `fm-pr-lib.sh` | Own canonical task and PR validation plus private atomic PR-poll publication and identity-bound retirement | | `fm-pr-poll.sh` | Provide the byte-static watcher program for validated PR/MR-poll sidecars | | `fm-pr-check-migrate.sh` | Quarantine older task polls without execution and rebuild only canonical polls | diff --git a/tests/fm-forge-credential.test.sh b/tests/fm-forge-credential.test.sh new file mode 100755 index 0000000000..a312533549 --- /dev/null +++ b/tests/fm-forge-credential.test.sh @@ -0,0 +1,1031 @@ +#!/usr/bin/env bash +# Behavior tests for fm-forge-credential.sh and the bootstrap detection built on +# it. +# +# The resolver is the only thing between an unattended poll and a forge, so the +# properties pinned here are the ones whose absence caused real incidents: +# - a complete pair resolves, and a half-resolved one never reaches a request +# - each refusal names WHICH requirement failed, with its own exit code, so +# "no credential" is never confused with "credential rejected" +# - the pair reaches curl through a config on stdin, never argv, and no +# diagnostic, output stream, or error path can emit its value +# - the resolver reads the keychain itself and never no-mistakes' separate +# write-capable entry +# Every case runs against a DUMMY pair through a fake keychain tool and a fake +# curl, so the suite needs no real credential and works on Linux CI. +set -u + +# shellcheck source=tests/lib.sh disable=SC1091 +. "$(dirname "${BASH_SOURCE[0]}")/lib.sh" + +RESOLVER="$ROOT/bin/fm-forge-credential.sh" +TMP_ROOT=$(fm_test_tmproot fm-forge-credential-tests) + +# The dummy pair. Nothing in the suite may print these, exactly as nothing in +# production may print the real ones; the leak assertions below check for them +# in every captured stream. +DUMMY_USER='dummy@example.invalid' +DUMMY_SECRET='dummy-token-value' + +# A fake `security` that answers only the firstmate-specific services. Each +# service's behavior is set per case through FAKE_KEYCHAIN_: +# ok - return the dummy value +# absent - exit 44, as a real keychain does for a missing entry +# refused - exit 51, as a real keychain does when the stored item's access +# control forbids an unattended read: the item is there, the store +# simply will not open it +# empty - return an empty value +# newline - return a value carrying an embedded line break +# stall - never answer, as a real keychain does when the item's access +# control raises a confirmation dialog no unattended session can +# answer; this is what the store watchdog exists for +make_fake_keychain() { # + local dir=$1 + mkdir -p "$dir" + cat > "$dir/security" <> "\${FAKE_KEYCHAIN_LOG:-/dev/null}" +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' ;; + newline) printf '%s\noutput = /tmp/fm-forge-injected\n' "\$value" ;; + colon) printf 'user:with:colons\n' ;; + stall) exec sleep 20 ;; +esac +SH + chmod +x "$dir/security" + printf '%s\n' "$dir/security" +} + +# A fake curl that records HOW it was called without ever recording the +# credential: it writes the literal argv it received (asserted credential-free) +# and a boolean for whether the expected Basic-auth config arrived on stdin. +# FAKE_CURL_STATUS sets the HTTP status it reports; FAKE_CURL_EXIT makes it fail +# like a transport error; FAKE_CURL_404_MATCH answers 404 for just the requests +# whose argv contains that substring, so one run can hold a repository the +# credential cannot see alongside one it can. +make_fake_curl() { # + local dir=$1 + mkdir -p "$dir" + cat > "$dir/curl" <> "\$argv_log" +config=\$(cat) +if [ "\$config" = 'user = "$DUMMY_USER:$DUMMY_SECRET"' ]; then + printf '%s\n' AUTH_PRESENT >> "\$stdin_log" +else + printf '%s\n' AUTH_ABSENT >> "\$stdin_log" +fi +out= +while [ "\$#" -gt 0 ]; do + case "\$1" in + --output) out=\$2; shift 2 ;; + *) shift ;; + esac +done +[ -z "\$out" ] || printf '%s\n' '{"fake":"body"}' > "\$out" +if [ -n "\${FAKE_CURL_EXIT:-}" ] && [ "\${FAKE_CURL_EXIT}" -ne 0 ]; then + printf '%s' 000 + exit "\$FAKE_CURL_EXIT" +fi +if [ -n "\${FAKE_CURL_404_MATCH:-}" ]; then + case "\$argv_all" in + *"\$FAKE_CURL_404_MATCH"*) printf '%s' 404; exit 0 ;; + esac +fi +printf '%s' "\${FAKE_CURL_STATUS:-200}" +SH + chmod +x "$dir/curl" +} + +# A fresh case dir with fake tools installed. mktemp rather than a counter: this +# runs inside a command substitution, so a counter would increment in a subshell +# and silently hand every case the same directory. +new_case() { + local dir + mkdir -p "$TMP_ROOT" + dir=$(mktemp -d "$TMP_ROOT/case.XXXXXX") + mkdir -p "$dir/bin" + make_fake_keychain "$dir/bin" >/dev/null + make_fake_curl "$dir/bin" + printf '%s\n' "$dir" +} + +# Run the resolver with the fake toolchain. Echoes "||". +run_resolver() { # + local dir=$1 out err status + shift + err="$dir/stderr" + out=$(PATH="$dir/bin:$PATH" \ + FM_FORGE_KEYCHAIN_TOOL_OVERRIDE="$dir/bin/security" \ + FAKE_KEYCHAIN_USER="${FAKE_KEYCHAIN_USER:-ok}" \ + FAKE_KEYCHAIN_SECRET="${FAKE_KEYCHAIN_SECRET:-ok}" \ + FAKE_KEYCHAIN_LOG="$dir/keychain-services" \ + FAKE_CURL_ARGV_LOG="$dir/curl-argv" \ + FAKE_CURL_STDIN_LOG="$dir/curl-stdin" \ + FAKE_CURL_STATUS="${FAKE_CURL_STATUS:-200}" \ + FAKE_CURL_EXIT="${FAKE_CURL_EXIT:-0}" \ + FM_FORGE_CREDENTIAL_TIMEOUT="${FM_FORGE_CREDENTIAL_TIMEOUT:-}" \ + FM_FORGE_KEYCHAIN_TIMEOUT="${FM_FORGE_KEYCHAIN_TIMEOUT:-}" \ + "$RESOLVER" "$@" 2>"$err") + status=$? + printf '%s|%s|%s' "$status" "$out" "$(cat "$err")" +} + +# Every captured stream must be free of both halves of the pair. This is the +# assertion that would have caught the real leak: a presence check written as +# ${VAR:+SET}${VAR:-UNSET} prints the value whenever the variable is set. +assert_no_credential_leak() { #