Skip to content
42 changes: 37 additions & 5 deletions bin/fm-azure-validation-guest.sh
Original file line number Diff line number Diff line change
Expand Up @@ -305,13 +305,30 @@ auth_home_pull() {
elif [ "${pulled:-0}" -eq 0 ]; then
# First-ever boot against an empty share: proceed, but leave a durable
# marker so the operator knows one interactive auth is still needed.
printf 'auth share %s was empty at %s; interactive provider auth is needed once\n' \
"$AUTH_SHARE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$STATE/auth-needed"
chmod 0600 "$STATE/auth-needed"
{ printf 'auth share %s was empty at %s; interactive provider auth is needed once\n' \
"$AUTH_SHARE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$STATE/auth-needed" \
&& chmod 0600 "$STATE/auth-needed"; } || :
echo "validation guest: auth share is empty; interactive auth marker written" >&2
else
rm -f "$STATE/auth-needed"
rm -f "$STATE/auth-needed" || :
fi
# From here on this cell owns auth the share has not seen, whether it came
# from the share, from the seeded bundle after a failed pull, or from a first
# interactive auth against an empty share. The owed marker is durable on the
# worktree disk, so a cell that dies before its clean shutdown - the only
# place the push runs - carries the skipped write-back into its report
# instead of losing it silently. Naming the actual origin keeps the marker
# from asserting a pull that did not happen.
if [ "$pull_rc" -ne 0 ]; then
owed_origin="the seeded bundle after a failed pull from share $AUTH_SHARE"
elif [ "${pulled:-0}" -eq 0 ]; then
owed_origin="a first interactive auth against empty share $AUTH_SHARE"
else
owed_origin="a pull from share $AUTH_SHARE"
fi
{ printf 'this cell owns auth from %s at %s; a write-back to %s is owed\n' \
"$owed_origin" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$AUTH_SHARE" >"$STATE/auth-push-owed" \
&& chmod 0600 "$STATE/auth-push-owed"; } || :
}

auth_home_push() {
Expand All @@ -320,7 +337,17 @@ auth_home_push() {
>>"$LOGS/auth-sync-a$ATTEMPT.log" 2>&1
push_rc=$?
set -e
[ "$push_rc" -eq 0 ] || echo "validation guest: auth-home push failed; refreshed tokens stay cell-local" >&2
if [ "$push_rc" -eq 0 ]; then
rm -f "$STATE/auth-push-owed" "$STATE/auth-push-failed" || :
return 0
fi
# A warning on stderr dies with the guest. The share is now stale, every
# later boot starts from an older credential, and only a durable marker
# carried into the operator report says so.
{ printf 'auth-home push to share %s failed with status %s at %s; refreshed tokens stay cell-local and the share is stale\n' \
"$AUTH_SHARE" "$push_rc" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$STATE/auth-push-failed" \
&& chmod 0600 "$STATE/auth-push-failed"; } || :
echo "validation guest: auth-home push failed; refreshed tokens stay cell-local" >&2
}

if [ "$MODE" = start ]; then
Expand Down Expand Up @@ -979,6 +1006,11 @@ REPORT=$STATE/report.md
if [ -f "$STATE/auth-needed" ]; then
printf -- "- Auth: \`interactive provider auth needed once (auth share empty)\`\n"
fi
if [ -f "$STATE/auth-push-failed" ]; then
printf -- "- Auth write-back: \`FAILED - refreshed tokens stayed cell-local and %s is stale\`\n" "$AUTH_SHARE"
elif [ -f "$STATE/auth-push-owed" ]; then
printf -- "- Auth write-back: \`SKIPPED - an attempt ended without reaching its clean-shutdown push to %s\`\n" "$AUTH_SHARE"
fi
} >"$REPORT"

RESULT_ARCHIVE=$BOOTSTRAP/result.tar.gz
Expand Down
177 changes: 177 additions & 0 deletions bin/fm-azure-validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
TEMPLATE = ROOT / "docs" / "azure-validation" / "cell.json"
GUEST = ROOT / "bin" / "fm-azure-validation-guest.sh"
SHARD_BRIDGE = ROOT / "bin" / "fm-azure-validation-shard-bridge.py"
CREDENTIAL_EXPIRY = ROOT / "bin" / "fm-credential-expiry.py"
CONTAINER = "validation-shards"
SCHEMA = "fm.azure-validation/v1"
RESULT_SCHEMA = "fm.azure-validation-result/v1"
Expand Down Expand Up @@ -975,6 +976,170 @@ def runner_module():
return _RUNNER_MODULE


_CREDENTIAL_EXPIRY_MODULE = None


def credential_expiry_module():
global _CREDENTIAL_EXPIRY_MODULE
if _CREDENTIAL_EXPIRY_MODULE is None:
spec = importlib.util.spec_from_file_location(
"credential_expiry_module", str(CREDENTIAL_EXPIRY)
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
_CREDENTIAL_EXPIRY_MODULE = module
return _CREDENTIAL_EXPIRY_MODULE


# What the fm-auth-home share actually is, verified against both consumers:
# the guest's auth_home_pull copies the WHOLE share into one cell home and the
# guest exports CODEX_HOME=$HOME/.codex or CLAUDE_CONFIG_DIR=$HOME/.claude, so
# the share is exactly one home-shaped tree holding at most one codex profile
# and one claude profile. Crosscheck reviewers never read the share at all;
# they receive a per-review credential archive. There is therefore no consumer
# for a multi-profile layout, and inventing one would write bytes nothing
# reads. Seeding keeps the layout the consumers already expect.
#
# Only the credential file itself is uploaded. Sessions, history, caches, and
# project state are cell-local by design and have no reason to sit on a shared
# Azure Files share.
AUTH_HOME_LAYOUT = {
"codex": (".codex", "auth.json"),
"claude": (".claude", ".credentials.json"),
}


def auth_seed_targets(args):
"""Resolve the requested harness/profile pairs for one seeding run."""

selected = []
for harness in PROVIDERS:
value = getattr(args, harness, None)
if not value:
continue
profile = Path(value).expanduser()
if not profile.is_dir() or profile.is_symlink():
raise ValidationError(
"{} profile must be an existing non-symlink directory: {}".format(
harness, profile
)
)
directory, credential = AUTH_HOME_LAYOUT[harness]
source = profile.resolve() / credential
if not source.is_file() or source.is_symlink():
raise ValidationError(
"{} profile holds no regular {} to seed: {}".format(
harness, credential, source
)
)
selected.append({
"harness": harness,
"profile": profile.resolve(),
"source": source,
"share_directory": directory,
"share_path": "{}/{}".format(directory, credential),
})
if not selected:
raise ValidationError(
"auth-seed requires at least one of --codex or --claude"
)
return selected


def auth_seed_preflight(targets):
"""Refuse to publish a credential the cells cannot authenticate with.

Seeding exists to carry a freshly re-authenticated profile onto the share.
A profile whose access token is already dead would be uploaded, pulled by
every later boot, and fail there instead of here, so it is refused with
its own expiry named.
"""

expiry = credential_expiry_module()
for target in targets:
record = expiry.inspect_profile(
target["profile"], harness=target["harness"]
)
try:
expiry.require_state(record, "usable", "fm-auth-home seed")
except expiry.CredentialExpiryError as exc:
raise ValidationError(
"{}; re-authenticate that profile and seed again".format(exc)
)
target["expiry"] = record
return targets


def auth_seed(env, args):
"""Publish selected local credentials onto the persistent auth share.

Plan is local and touches no Azure. Apply requires the exact subscription
plus an explicit seed confirmation, uploads each credential to the exact
path its consumer reads, and then re-reads the share to prove the upload.
"""

targets = auth_seed_preflight(auth_seed_targets(args))
share = auth_share_name()
if not share:
raise ValidationError("FM_AZURE_AUTH_SHARE is empty; the auth-home sync is disabled")
if not args.apply:
print("auth-seed plan (no Azure call made)")
print(" share: {}".format(share))
for target in targets:
print(" {} {} -> {}".format(
target["harness"], target["source"], target["share_path"]
))
print(" state {} expires {}".format(
target["expiry"]["state"], target["expiry"]["expires_at"]
))
print(" apply with: --apply --confirm-seed --confirm-subscription <exact-id>")
return
if not args.confirm_seed:
raise ValidationError("auth-seed --apply requires --confirm-seed")
if args.confirm_subscription != env["subscription"]:
raise ValidationError("auth-seed --apply requires the exact --confirm-subscription")
scope_gate(env)
backup = ["--auth-mode", "login", "--enable-file-backup-request-intent"]
for target in targets:
az_command(env, [
"storage", "directory", "create",
"--account-name", env["storage"],
"--share-name", share,
"--name", target["share_directory"],
] + backup)
_, code, detail = az_command(env, [
"storage", "file", "upload",
"--account-name", env["storage"],
"--share-name", share,
"--source", str(target["source"]),
"--path", target["share_path"],
] + backup, check=False)
if code != 0:
raise ValidationError("auth-seed upload failed for {}: {}".format(
target["share_path"], detail
))
# An accepted upload is not proof: re-read the share and require the
# exact byte count, so a truncated or replaced object is caught here
# instead of at the next cell boot.
published, code, detail = az_command(env, [
"storage", "file", "show",
"--account-name", env["storage"],
"--share-name", share,
"--path", target["share_path"],
] + backup, check=False)
expected = target["source"].stat().st_size
published_size = ((published or {}).get("properties") or {}).get("contentLength")
if code != 0 or published_size != expected:
raise ValidationError(
"auth-seed could not prove {} landed at its expected {} bytes: {}".format(
target["share_path"], expected, detail or published_size
)
)
print("auth-seed published {} ({} bytes, expires {})".format(
target["share_path"], expected, target["expiry"]["expires_at"]
))


def lifecycle_command(env, arguments):
command_env = os.environ.copy()
# The allocator store is fenced to its home identity, so the operator's
Expand Down Expand Up @@ -2862,6 +3027,12 @@ def parser():
retain_parser.add_argument("--confirm-retain", action="store_true")
retain_parser.add_argument("--confirm-subscription")
commands.add_parser("queue")
seed_parser = commands.add_parser("auth-seed")
seed_parser.add_argument("--codex")
seed_parser.add_argument("--claude")
seed_parser.add_argument("--apply", action="store_true")
seed_parser.add_argument("--confirm-seed", action="store_true")
seed_parser.add_argument("--confirm-subscription")
pure = commands.add_parser("pure-check", help=argparse.SUPPRESS)
pure.add_argument("--fixture", required=True)
return root
Expand All @@ -2874,6 +3045,10 @@ def main():
pure_check(args)
return 0
cloud = args.command in ("dispatch", "drive", "observe", "collect", "respond", "replace", "close", "retain-failure")
# Planning a seed is a purely local credential read; only the upload
# needs a cloud scope, so a plan works without Azure environment.
if args.command == "auth-seed" and args.apply:
cloud = True
env = environment(require_cloud=cloud)
if args.command == "submit":
submit(env, args)
Expand All @@ -2897,6 +3072,8 @@ def main():
queue(env)
elif args.command == "status":
status(env, args)
elif args.command == "auth-seed":
auth_seed(env, args)
return 0
except ValidationError as exc:
print("AZURE VALIDATION FAILED: {}".format(exc), file=sys.stderr)
Expand Down
13 changes: 11 additions & 2 deletions bin/fm-azure-validation.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,19 +42,28 @@
# fm-azure-validation.sh retain-failure --cell <azv-id> --confirm-retain \
# --confirm-subscription <exact-id>
# fm-azure-validation.sh queue
# fm-azure-validation.sh auth-seed [--codex <profile>] [--claude <profile>]
# [--apply --confirm-seed --confirm-subscription <exact-id>]
#
# auth-seed publishes a locally re-authenticated credential onto the
# fm-auth-home share so cells stop booting with a dead token. Without --apply
# it plans locally and makes no Azure call. It refuses any profile whose
# credential is not usable now (bin/fm-credential-expiry.py owns that
# judgement) and uploads only the credential file, into the one home-shaped
# layout the guest actually reads.
set -euo pipefail

SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)

usage() {
sed -n '2,44p' "$0" | sed 's/^# \{0,1\}//'
sed -n '2,54p' "$0" | sed 's/^# \{0,1\}//'
}

case "${1:-}" in
help|-h|--help|"")
usage
;;
submit|dispatch|drive|observe|collect|status|respond|replace|close|retain-failure|queue)
submit|dispatch|drive|observe|collect|status|respond|replace|close|retain-failure|queue|auth-seed)
exec python3 "$SCRIPT_DIR/fm-azure-validation.py" "$@"
;;
*)
Expand Down
Loading
Loading