Skip to content

fix(secrets): preserve default_exposed on fal secrets set - #1121

Draft
rehan-remade wants to merge 2 commits into
mainfrom
rehan/secrets-set-preserve-default-exposed
Draft

fix(secrets): preserve default_exposed on fal secrets set#1121
rehan-remade wants to merge 2 commits into
mainfrom
rehan/secrets-set-preserve-default-exposed

Conversation

@rehan-remade

Copy link
Copy Markdown
Contributor

Problem

fal secrets set NAME=VALUE always sent an explicit default_exposed, so using it to rotate a value also reset the secret's exposure setting.

--not-exposed-by-default was a bare store_true, so args.not_exposed_by_default is False whenever the flag is absent, and _set computed:

# projects/fal/src/fal/cli/secrets.py:13 (before)
default_exposed=not args.not_exposed_by_default,

Since not <bool> is always a bool, the CLI structurally could never send None. But None is what the API means by "no opinion", per the docstring on SecretsClient.set in projects/fal/src/fal/api/client.py:

default_exposed: Whether the secret is exposed to apps that do not explicitly list their secrets. None keeps the account-level default (and preserves the current setting on updates).

So the sequence below did not do what it looks like it does. The second command silently undid the first, putting the secret back to being injected into apps that do not list their own secrets=[...]:

$ fal secrets set HF_TOKEN=hf_old --not-exposed-by-default   # opt out
$ fal secrets set HF_TOKEN=hf_new                            # rotate the value... and re-expose it

Fix

Make the exposure flags a tri-state that matches the API:

invocation value sent
fal secrets set A=b None (leave exposure alone)
fal secrets set A=b --not-exposed-by-default False
fal secrets set A=b --exposed-by-default True (new flag)

Both flags share dest="default_exposed" with default=None in a mutually exclusive group, so the existing --not-exposed-by-default flag name keeps working unchanged.

Two notes on the approach:

  • argparse.BooleanOptionalAction would be tidier, but it needs Python 3.9 and this package declares requires-python = ">=3.8". It would also rename the existing flag.
  • --exposed-by-default is new and necessary rather than just convenient: a bare set no longer implies True, so without it there would be no way to opt a secret back in from the CLI.

fal secrets list already renders the third state as account default when the value is None, so this makes the write side able to express what the read side could already display.

Behavior change

Creating a new secret via the CLI now sends None instead of True, which means the account-level default decides rather than the CLI pinning the secret to exposed.

For accounts using the default setting this is equivalent and nothing changes. For an account whose default has been set to not-exposed, newly created secrets are no longer exposed to apps that do not list them, which is the intended meaning of that setting but is a change in observable behavior. Those apps can either declare secrets=[...] or the secret can be created with --exposed-by-default.

Test plan

The args-to-API translation at cli/secrets.py:13 had no coverage: the existing tests asserted only the parsed argparse Namespace, never what reached client.secrets.set. That gap is how this got through in #1103. This PR adds tests that assert the forwarded value.

  • pre-commit run --files projects/fal/src/fal/cli/secrets.py projects/fal/tests/unit/cli/test_secrets.py: all hooks pass (ruff-format, ruff, mypy).
  • uv run --extra dev python -m pytest tests/unit/cli/: 6 failed, 224 passed on this branch, versus 6 failed, 218 passed on a pristine origin/main worktree. The 6 failures are pre-existing in test_deploy.py and unrelated to this change, so the delta is exactly the 6 new tests.
  • fal secrets set --help renders the pair as [--not-exposed-by-default | --exposed-by-default], and passing both errors with argument --exposed-by-default: not allowed with argument --not-exposed-by-default.

Not run: integration and e2e tests, per AGENTS.md, since credentials are not available here. This change was not exercised end to end against a live backend.

🤖 Generated with Claude Code

rehan-remade and others added 2 commits July 30, 2026 14:29
`fal secrets set NAME=VALUE` always sent an explicit `default_exposed`, so a
plain value rotation overwrote whatever exposure the secret already had.
`--not-exposed-by-default` was a bare `store_true`, making
`args.not_exposed_by_default` False when the flag was absent, and the value was
computed as `not args.not_exposed_by_default`. Since `not <bool>` is always a
bool, the CLI could never send `None`.

`None` is what the API means by "no opinion". Per the docstring on
`SecretsClient.set` in `fal/api/client.py`, it "keeps the account-level default
(and preserves the current setting on updates)". Because the CLI never sent it,
re-running `set` to rotate a value also reset the secret's exposure, undoing an
earlier `--not-exposed-by-default`.

Make the exposure flags a tri-state that matches the API:

  (no flag)                 sends None, leaving exposure alone
  --not-exposed-by-default  sends False
  --exposed-by-default      sends True (new)

Both share `dest="default_exposed"` with `default=None` in a mutually exclusive
group, so the existing `--not-exposed-by-default` flag name keeps working.
`--exposed-by-default` is new and needed because a bare set no longer implies
True, so it would otherwise be impossible to opt a secret back in from the CLI.
`BooleanOptionalAction` would be tidier but needs Python 3.9, and this package
supports 3.8.

The args-to-API translation had no test coverage, which is how this got through
in #1103. Add tests asserting the value actually forwarded to
`client.secrets.set`, not just the parsed Namespace.

Co-Authored-By: Claude <noreply@anthropic.com>
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