Skip to content

fix(spawn): persist every cloud-lane name the closed monitor pane must read - #286

Merged
ruby-dlee merged 4 commits into
mainfrom
fix/cloud-env-allowlist-provider-contract
Aug 21, 2026
Merged

fix(spawn): persist every cloud-lane name the closed monitor pane must read#286
ruby-dlee merged 4 commits into
mainfrom
fix/cloud-env-allowlist-provider-contract

Conversation

@ruby-dlee

@ruby-dlee ruby-dlee commented Aug 21, 2026

Copy link
Copy Markdown
Owner

The defect

A live Azure acceptance run: a secondmate compartment requested a crewmate child, the controller admitted it with every binding right, then creating the child's worker failed.

provider mutate failed: AZURE WORKER PROVIDER REFUSED: landed singleton worker deployment
failed: REFUSED: FM_AZURE_TENANT_ID is required and must be supplied out of band

SPAWN_CLOUD_ENV_ALLOWLIST (bin/fm-spawn.sh) is the set of variables persisted into state/<id>.cloud-env. The compartment and crewmate monitors source that file in a subshell for every lifecycle call they make, because their Herdr panes are deliberately CLOSED and inherit nothing from the operator's shell. A name missing from that file is unreachable in production on every machine, in every configuration. The compartment's own VM was created fine only because that placement ran from the operator's shell.

It was not one omission. It was 21

The allowlist's own regeneration recipe was the bug's source:

grep -ohE 'FM_AZURE_[A-Z0-9_]+' bin/fm-worker-lifecycle.py bin/fm-azure-worker-provider.py | sort -u

The deployment does not run in the provider. run_pilot_create shells out to bin/fm-azure-pilot.sh worker-create, a subprocess that reads its own environment. That grep could never see it. Reconciled against the readers, the persist side was missing 21 names.

Refuse outright (require_cloud_environment / os.environ[...] in make_parameters_file):

FM_AZURE_TENANT_ID, FM_AZURE_ADMIN_EMAIL, FM_AZURE_ADMIN_USERNAME, FM_AZURE_ADMIN_SSH_PUBLIC_KEY, FM_AZURE_RUNNER_OPERATOR_OBJECT_ID, FM_AZURE_KEY_VAULT_NAME, FM_AZURE_BUDGET_START_DATE

Silently drift to a default (os.environ.get, all seven defined in the operator's own fleet.env or read by the lifecycle):

FM_AZURE_WORKER_IMAGE_ID (the worker VM image), FM_AZURE_OPERATOR_DATA_PLANE_IP, FM_AZURE_RUNNER_VALIDATION_SKU, FM_AZURE_STEADY_STATE_BUDGET_TARGET_USD, FM_AZURE_VM_FAMILY, FM_AZURE_PROTECT_DURABLE_STATE, FM_AZURE_MUTATION_STATE_DIR, FM_AZURE_CLEANUP_TIMEOUT_SECONDS, FM_AZURE_WORKER_SLOTS, FM_AZURE_WORKER_SKUS, FM_AZURE_SECONDMATE_MAX, FM_AZURE_WORKER_DAILY_BOUND_USD, FM_AZURE_WORKER_DAILY_BOUND_OVERRIDE, FM_AZURE_WORKER_IDLE_RELEASE_SECONDS

The last four were missing even from the grep the comment claimed the line was generated by, so the line and its stated source already disagreed. Fixing only the reported name would have moved the wall one variable further in.

THREE names leave the allowlist: FM_AZURE_CAPACITY_PROFILE, FM_AZURE_AUTHOR_CAPACITY_MODE, and FM_AZURE_WORKER_COST_ATTRIBUTION. All three appear in the provider only inside run_pilot_create's env.update, which hard-sets each, so an operator copy was dead weight that could only ever be a stale override.

Nothing secret-bearing enters. Every added name is an identifier, a bound, a path, or a public key. The prefix genuinely does carry credentials elsewhere in the fleet: FM_AZURE_VALIDATION_CREDENTIAL_KEY_FILE, FM_AZURE_VALIDATION_WORKTREE_KEY_FILE, and FM_AZURE_GITHUB_TOKEN_FILE (bin/fm-azure-validation.py:464). Those stay out because no scanned reader names them, which is a fact about today's readers and NOT a property the scan guarantees. Claiming otherwise would be a comment asserting a guarantee the code does not provide, which is the shape that produced this defect. The guard is bidirectional instead.

The fix

bin/fm-cloud-env-contract.py is the one owner of the set. It derives the names from the FOUR readers (bin/fm-azure-pilot.sh, bin/fm-azure-worker-provider.py, bin/fm-worker-lifecycle.py, bin/fm-worker-lifecycle.sh), subtracts the seven the provider supplies per placement, and carries an explicit SECRET_BEARING_EXCLUSIONS tuple so the allowlist stays a reviewed literal and not a prefix glob. The subtraction is sliced out of run_pilot_create by ast, from the top-level FunctionDef/AsyncFunctionDef with that name, using end_lineno. Every derivation step fails loudly rather than returning an empty set. bin/fm-spawn.sh now carries the regenerated literal and a comment that points at the contract instead of the stale grep.

The test, and its proven-red evidence

test_persisted_cloud_env_matches_the_deployment_read_set_exactly in tests/fm-spawn-cloud.test.sh is effect-shaped and never mentions the reported variable. It derives the contract, exports a shape-valid value for every probe name, runs a REAL cloud spawn, and then sources the file the spawn actually wrote in an env -i environment scrubbed to what a Herdr pane keeps, comparing value by value.

It asserts EQUALITY, not containment, and the probe environment is deliberately wider than the contract: the contract unioned with every name the allowlist currently spells, so an extra name actually reaches the file and fails on the file's own contents. A missing name is an outage. An EXTRA name is the more dangerous failure, because SPAWN_CLOUD_ENV_ALLOWLIST is what keeps a secret-bearing FM_AZURE_* off disk, and a containment-only assertion let anyone widen it and stay green. Two vacuity guards, one per set, so neither half can silently disarm.

Red with the allowlist reverted to its pre-fix value, on its own wording:

not ok - the persisted cloud-env cannot reach the deployment path: the closed pane never sees
FM_AZURE_ADMIN_EMAIL FM_AZURE_ADMIN_SSH_PUBLIC_KEY FM_AZURE_ADMIN_USERNAME FM_AZURE_BUDGET_START_DATE
FM_AZURE_CLEANUP_TIMEOUT_SECONDS FM_AZURE_KEY_VAULT_NAME FM_AZURE_MUTATION_STATE_DIR
FM_AZURE_OPERATOR_DATA_PLANE_IP FM_AZURE_PROTECT_DURABLE_STATE FM_AZURE_RUNNER_OPERATOR_OBJECT_ID
FM_AZURE_RUNNER_VALIDATION_SKU FM_AZURE_SECONDMATE_MAX FM_AZURE_STEADY_STATE_BUDGET_TARGET_USD
FM_AZURE_TENANT_ID FM_AZURE_VM_FAMILY FM_AZURE_WORKER_DAILY_BOUND_OVERRIDE
FM_AZURE_WORKER_DAILY_BOUND_USD FM_AZURE_WORKER_IDLE_RELEASE_SECONDS FM_AZURE_WORKER_IMAGE_ID
FM_AZURE_WORKER_SKUS FM_AZURE_WORKER_SLOTS

Red in the other direction too. Adding a real code read to bin/fm-azure-pilot.sh with the fix in place:

not ok - ... the closed pane never sees FM_AZURE_ZZZ_DRIFT_PROBE

Four more, all executed, all raised by the review:

  • FM_AZURE_VALIDATION_CREDENTIAL_KEY_FILE and FM_AZURE_GITHUB_TOKEN_FILE added to the allowlist with no reader naming them: red with writes names no reader on the deployment path asks for.
  • A name put into SECRET_BEARING_EXCLUSIONS while left in the allowlist: red the same way. The tuple has enforcement power now; before, it was a comment.
  • env.update hoisted out of run_pilot_create into a helper defined later in the same file: the contract refuses by name. The old regex was unbounded at the start, matched the unrelated helper, and returned an identical-looking 37-name set, rc=0, test green, while a real deployment would have run at capacityProfile=foundation with all four worker bindings "unbound".
  • A bare comment mentioning FM_AZURE_CLIENT_SECRET inside a Python reader: no longer pulls it into the contract, which removes the bad steering at its source rather than arguing with it afterwards. See the scan's exact limits below.

That is the whole point: a name added to one side and not the other goes red without the test having heard of it.

Green: tests/fm-spawn-cloud.test.sh (26 assertions), tests/fm-secondmate-cloud-monitor.test.sh, tests/fm-cloud-state.test.sh, bin/fm-lint.sh (exit 0). All through python3 tests/run-one.py. No Azure resource was created, read, or mutated.

What the scan actually does, stated exactly

Overstating this is the mistake the PR exists to catch, so:

  • Python readers are tokenized, and dropping COMMENT tokens is exact for # only. It is not a claim about mentions in general. A name inside a docstring or any other string literal is still scanned and still enters the contract, and docstrings are the natural place to document environment variables in Python, so that is a live case rather than a corner. It over-includes, which is the safe direction, and SECRET_BEARING_EXCLUSIONS is the answer.
  • Shell readers are not stripped at all. The earlier "conservative, never under-includes" claim was false: a # in shell is only sometimes a comment, and a line beginning #$VAR inside a multi-line double-quoted string or an unquoted heredoc body genuinely expands, so a startswith("#") filter drops a real read. Under-include is the one direction that silently loses a name and recreates the outage. No cheap shell parse tells the cases apart, so the scan no longer tries: shell comments count as reads. A raw-versus-stripped diff shows no name disappearing from any reader, and the derived set is unchanged at 37 either way.
  • bin/fm-worker-lifecycle.sh contributes nothing to the set. All 16 of its FM_AZURE_ names sit in its header documentation and every one is covered by the other three readers. It is scanned rather than exempted, so the day it grows a real read the contract sees it, but it should not be mistaken for coverage. Dropping the shell stripping also retired the MAY_BE_EMPTY entry this reader previously needed, so it is no longer permanently quiet-exempt.

Round three, four more, all executed:

  • env.update moved into an async def helper right after run_pilot_create: the terminator regex did not know async def, so it returned the identical 37 names, rc=0, green. The ast slicer refuses by name.
  • run_pilot_create made the last top-level def, with module-level env = os.environ.copy(); env.update({...}) after it: the regex fell through to end = len(source) and swallowed it, again 37 and green. Also refuses now.
  • Decorator, nested def, class-method decoy, and a docstring containing def all still derive correctly (the class-method decoy correctly adds its name to the contract as a genuine reader mention, which is the safe direction).
  • A rogue printf 'export FM_AZURE_CLIENT_SECRET=%q\n' added to spawn_cloud_persist_convergence_artifacts, outside the allowlist loop: previously green, because the equality was intersected with the probe first. Now red. A rogue export is also inert unless its variable is set, so the probe additionally carries every literal export NAME= the persist block can emit; the guard on that extraction caught its own first version reading one name of three, and a non-FM_AZURE rogue (FM_SNEAKY_TOKEN) is caught too.

All three vacuity guards fire for real: rewriting the allowlist assignment to double quotes yields only 0 allowlist names could be read, and the literal-export guard fired during development as described above.

Verified under #282's ambient seal

Rebased onto 6a41486d; #282 and #281 both landed while this was in flight. The
tests/fm-spawn-cloud.test.sh overlap with #282 rebased cleanly (different
regions), and the derived contract is unchanged at 37 names, byte-equal to the
allowlist literal.

The seal question was real and had to be established rather than assumed, because
this test's whole method is to export a value for every contract name and compare the
persisted file value by value, and FM_AZURE_* is exactly what the seal drops. Re-run
through tests/run.sh with the operator's fleet.env sourced and FM_HOME/FM_SPAWN_CLOUD
exported (run-one.py is the reaper, not the entry point, and bypasses all of this):

  • The seal fires: FM_AMBIENT_SEAL dropped= 27 names, all 25 FM_AZURE_* plus FM_HOME and FM_SPAWN_CLOUD.
  • It does not touch this test's probe values. The seal unsets in the runner process before any test is admitted; this test exports inside the test function, after admission. Both facts are true at once and they do not collide.
  • The value-exactness assertion is not vacuous. The persisted file was captured from a sealed run: 39 export lines, zero empty-valued exports, and the values are the test's own sentinels (FM_AZURE_TENANT_ID=fmcontract-FM_AZURE_TENANT_ID), not the operator's real ones. FM_AZURE_SUBSCRIPTION_ID carries the harness fixture UUID rather than the operator's subscription, which is the seal's effect visible in the artifact.
  • It could not have been vacuous either way: the persist loop tests ${!var+x} (set, not non-empty), so a sealed-empty value would still be written and then fail the value comparison, while an unset one fails the missing check.
  • No cloud reach recorded in any sealed run, and tests/fm-cloud-provider-seal.test.sh passes with the operator environment present.

All red directions re-driven SEALED, all reproducing identically:

# Direction Sealed result
D1 allowlist reverted to pre-fix red, 21 names
D2 secret-bearing names in allowlist, no reader red, extras
D3 name in SECRET_BEARING_EXCLUSIONS, left in allowlist red, extras
D4 real code read added to a reader red, missing
D5 allowlist assignment rewritten to double quotes red, vacuity guard
D6 rogue export outside the allowlist loop red, extras
D7 env.update hoisted into an async def helper red, derivation refuses

D7 surfaced one real defect the unsealed runs had hidden: the assertion captured
the contract's stdout only, so a derivation that refuses printed could not be derived: with nothing after the colon. Red for the right reason, silent about
what it was. Fixed in 99706e2d, stderr captured separately so it reaches the
message without polluting the name list.

Sealed with the operator environment present, all green: fm-spawn-cloud,
fm-secondmate-cloud-monitor, fm-cloud-state, fm-cloud-provider-seal,
fm-worker-lifecycle. bin/fm-lint.sh exit 0.

The drift question

Five places knew "which FM_AZURE_* a cloud lane needs", and they shared no source:

  1. SPAWN_CLOUD_ENV_ALLOWLIST (bin/fm-spawn.sh:4523) - the persist side
  2. require_cloud_environment + make_parameters_file (bin/fm-azure-pilot.sh:113,556) - the deployment read side
  3. environment() (bin/fm-azure-runner.py:371) - the runner lane
  4. runtime_config() (bin/fm-crosscheck-azure.py:295) - the crosscheck lane
  5. the required tuple in bin/fm-azure-validation.py:244 - the validation cell lane

Plus two non-code copies: the prose lists in docs/azure-crosscheck.md and docs/azure-pilot.md, and the operator's own ~/.fm-azure/fleet.env.

After this PR, 1 derives from 2 mechanically and is test-enforced against the persisted file in both directions, so that pair can no longer drift. 3, 4 and 5 still learn their sets independently. Each is a read-side guard that refuses by name, so a divergence there fails loudly instead of silently, which is why this PR does not fold them in.

BUT THAT LOUDNESS IS WORTH EXACTLY WHAT IT WAS WORTH HERE, WHICH IS NOT MUCH. The pilot also refuses by name. That is precisely the refusal in this bug report, and it saved nobody: it took a live Azure acceptance run on a real subscription to surface, because no hermetic test reaches those lanes either. "Loud" in lanes 3, 4 and 5 means loud at runtime, in front of an operator, after money and a lease are already committed. It does not mean caught in CI. Treat their independence as unmeasured, not as safe.

Sibling lanes

  • Ordinary cloud crewmate. Same cloud-env file, same allowlist, same closed pane, so it carried the same 21 omissions. It has not bitten because bin/fm-spawn-cloud-monitor.sh only ever dispatches execute; it never runs reconcile --apply, so it never reaches the pilot. It is fixed here by construction.
  • Compartment monitor. bin/fm-secondmate-cloud-monitor.sh sources cloud-env at five call sites and fm-secondmate-cloud-monitor.py's spawn_environment passes os.environ through rather than keeping a second list, so the child's own spawn re-persists from what the parent's file supplied. One list, correctly, and this PR fills it.
  • Validation cell and crosscheck compartment. Neither persists an allowlisted environment. bin/fm-spawn.sh:4598-4626 holds the only lines that emit into $STATE/$ID.cloud-env; the repo does contain other export NAME= writers, such as bin/fm-bootstrap.sh:723 writing export FM_HOME= into generated shims, which is a different channel entirely. Both lanes keep their own required-env list (4 and 5 above) and refuse by name when it is unmet, so the silent-omission shape cannot hide there. They carry the drift, not the defect. Not fixed here.

Known gaps, stated not built

  • FM_SECONDMATE_CHILD_MAX and FM_SECONDMATE_CHILD_TOTAL are the same defect class, one line away. They sit at bin/fm-worker-lifecycle.py:352-353, immediately after the FM_AZURE_SECONDMATE_MAX this PR does add, read through the same _bounded_env_int helper, and they gate compartment-child fan-out at :3038-3055, the exact lane this PR fixes. The contract's FM_AZURE_ regex is prefix-blind and misses them, so an operator's FM_SECONDMATE_CHILD_MAX=8 is silently ignored in the closed pane and the 4/16 defaults apply. Not an outage, because they have defaults. Not fixed here, and widening the regex would be a fix that does not fix: spawn_environment (bin/fm-secondmate-cloud-monitor.py:1376-1379) deliberately pops every FM_SECONDMATE_* key before the child spawn, for documented reasons, so persisting them would still not reach the lifecycle process that verifies the child request. Closing it properly means deciding which side of that pop the child bounds belong on, which is its own change.
  • No spawn-time preflight for the 12 names bin/fm-azure-pilot.sh:116-128 mandates. They are still enforced only at the pilot, after the request is admitted and the account lease is taken, so a missing name surfaces mid-lifecycle rather than at spawn. Worth building; not built here.
  • bin/fm-worker-lifecycle.sh joins the scanned readers in this PR. It is in the chain (bin/fm-spawn.sh:4373) and has zero non-comment FM_AZURE_ occurrences today, so it was unguarded rather than unsafe.

Dongkeun Lee added 4 commits August 21, 2026 06:09
…t read

A secondmate compartment could never create a child crewmate's worker. The
controller admitted the request with every binding right, then the Azure
provider refused: FM_AZURE_TENANT_ID is required and must be supplied out of
band. SPAWN_CLOUD_ENV_ALLOWLIST is the set written into state/<id>.cloud-env,
which the monitors source in a subshell for every lifecycle call; their Herdr
panes inherit nothing from the operator's shell, so a name absent from that
file is unreachable in production. The allowlist carried 19 names and the
deployment path reads 37.

The gap was structural, not a typo. The allowlist's own regeneration recipe
grepped only bin/fm-worker-lifecycle.py and bin/fm-azure-worker-provider.py,
but the deployment runs in bin/fm-azure-pilot.sh, a SUBPROCESS the provider
launches from run_pilot_create. Twelve names its require_cloud_environment
refuses without, and the parameter file it builds, were invisible to that
grep. Seven of them refuse outright; the rest, including the worker VM image
id, would have drifted silently to a default.

bin/fm-cloud-env-contract.py is now the one owner of that set: it derives the
names from the readers, subtracts the seven the provider supplies per
placement, and records secret-bearing exclusions explicitly so the allowlist
stays a reviewed literal rather than a prefix glob.

The new behavior test is effect-shaped. It derives the contract, exports a
shape-valid value for every name, runs a real cloud spawn, then sources the
persisted file in a scrubbed environment and compares value by value. It never
names the reported variable, so a name added to a reader and not to the
allowlist goes red on its own.
…or the derivation

Review found the guard failed loud in the wrong direction. The test asserted
contract subset-of persisted and never the reverse, and nothing else in the
repo constrained SPAWN_CLOUD_ENV_ALLOWLIST, so adding real secret-bearing
names to it with no reader stayed green, and SECRET_BEARING_EXCLUSIONS had no
enforcement power at all. Worse, a bare comment in a reader pulled a name into
the derived contract, and the remediation the failure printed steered the
developer toward persisting it.

The test now asserts EQUALITY. Its probe environment is the contract unioned
with every name the allowlist currently spells, so an extra name that no reader
asks for actually reaches the persisted file and fails there, on the file's own
contents rather than on a source read. Both failure messages name
SECRET_BEARING_EXCLUSIONS as the lever, and the extraction carries its own
vacuity guard so a broken read cannot silently disarm the half of the test that
looks for extras.

The scan is now comment-blind: Python readers are tokenized, shell readers drop
whole-line comments. That removes the steering at its source rather than
arguing with it afterwards, and the derived set is unchanged at 37 names
because no name was ever comment-only. It over-includes rather than under-
includes by design, since a trailing shell comment still counts.

provider_supplied is anchored to run_pilot_create's own body, top-level def to
top-level def. The previous regex was unbounded at the start, so hoisting the
env.update into a helper defined later in the same file matched the helper and
returned an identical-looking 37-name set while the real deployment would have
run at capacityProfile=foundation with every worker binding left unbound. That
refactor now refuses by name.

bin/fm-worker-lifecycle.sh joins the scanned readers. It is in the chain and
names nothing today, so it was unguarded rather than safe.
…ity to the file

Both round-two findings were the same species as the original defect: a comment
asserting a safety the code did not provide.

The provider-supplied slice is now taken by ast, from the top-level
FunctionDef or AsyncFunctionDef named run_pilot_create, using end_lineno. The
terminator regex it replaces had to enumerate the shapes that end a function
and missed two, both of which reopened the hole it existed to close: `async
def` was not in the terminator, and a run_pilot_create that is the LAST
top-level def fell through to end-of-file and swallowed module-level code after
it. Each returned the identical 37 names with rc=0 while the real deployment
would have run at capacityProfile=foundation with every worker binding unbound.
Both now refuse by name. Decorator, nested def, class-method decoy and a
docstring containing "def " all still behave.

The test's equality was scoped to the probe, not to the file: it intersected
the file's contents with the probe before comparing, so any name written by a
path other than the allowlist loop was outside the assertion entirely. The
persist block really does have such paths, so they are now named in
CLOUD_ENV_NON_ALLOWLIST_EXPORTS and every other name in the file is compared,
whatever wrote it and whatever its prefix. A rogue export is also inert unless
its variable is set, so the probe additionally carries every literal `export
NAME=` the persist block can emit; the guard on that extraction caught its own
first version reading one name of three.

code_text no longer overstates itself. Tokenizing is exact for `#` only, and a
name in a docstring or any string literal still enters the contract, which is
the safe direction and now says so. Shell is no longer stripped at all: a `#`
line inside a multi-line double-quoted string or an unquoted heredoc body
genuinely expands, and dropping it would be an under-include, the one direction
that silently loses a real name. The union is unchanged by that choice, and it
retires MAY_BE_EMPTY along with the permanent quiet-exemption it granted
bin/fm-worker-lifecycle.sh.
…e message

Found by re-driving the red directions through the sealed entry point rather
than through run-one.py. Every ContractError the derivation raises goes to
stderr, and the assertion captured stdout only, so a derivation that refuses
produced "the cloud-env contract could not be derived:" with nothing after the
colon. The async-def hoist was red for the right reason and said nothing about
what it was.

stderr now goes to its own file and into the message. Not folded into the name
list, because that would put refusal text where names are expected.
@ruby-dlee
ruby-dlee force-pushed the fix/cloud-env-allowlist-provider-contract branch from 85a8c36 to 99706e2 Compare August 21, 2026 10:35
@ruby-dlee
ruby-dlee merged commit 979f07e into main Aug 21, 2026
13 checks passed
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