Stop an untouched secret field from deleting the stored credential

Making `null` actually clear a secret — which it had to, since a blanked
token was previously never revoked — turned the config editors into a
credential shredder. Secrets are `#[serde(skip_serializing)]`, so the
inputs always render empty whether or not one is stored, and the blur
handlers sent `value || null` unconditionally. Focusing the Git token
field and tabbing away deleted it, with nothing shown and no undo.

`useSecretField` encodes the rule: only a field the user typed in may
speak about a secret. Untouched, `patch()` contributes no key at all, and
Rust already distinguishes an absent key from an explicit null.

`withoutUntouchedSecrets` covers the structural half. `saveBedrock`
spreads `{ ...bedrock, ...patch }`, and when that falls back to
DEFAULT_BEDROCK_CONFIG the literal spells every secret out as `null` — so
editing the AWS region would have wiped the credentials as a side effect.

Also here: `WorkspaceSection` no longer saves a half-filled folder row,
which `update_project`'s new validation would refuse on every keystroke
between the two inputs; `snapshots_skipped` is declared on the wire type
rather than widened locally; and `#[must_use]` on `ProjectGuard` and
`ScrubOutcome` — which immediately caught the migration path discarding
its scrub outcome, the one scrub whose silence is expensive because the
layer it declined to clean is about to be committed.

The capability test reads the real file and is mutation-verified: adding
`core:default` back makes it fail. That grant pulls in an unscoped
`std::fs::read` of any host path and went unnoticed for months, because
nothing in the suite read the file at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 13:23:00 -07:00
co-authored by Claude Opus 5
parent 7bbb699e4e
commit 6a8972980d
11 changed files with 354 additions and 49 deletions
+5 -1
View File
@@ -2371,6 +2371,10 @@ const SCRUB_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);
/// The distinction that earns this type is [`ScrubOutcome::NotRunning`] versus
/// [`ScrubOutcome::Failed`]: "there was nothing to exec into" is routine, while
/// "the exec ran and broke" is the one case worth a warning in the log.
/// `#[must_use]` because the variants mean different things to a caller and
/// three of the four are easy to ignore by accident: a `Failed` swallowed at a
/// call site is exactly how a scrub that stopped working would stay quiet.
#[must_use = "a scrub that was skipped, timed out or failed reads the same as one that worked unless the outcome is inspected"]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrubOutcome {
/// The scrub ran to completion and freed this many bytes — possibly zero,
@@ -2397,7 +2401,7 @@ impl ScrubOutcome {
/// nothing to exec into. A figure of zero reads as a scrub that ran and
/// found nothing, which is a different and more alarming thing than a
/// scrub that correctly had no work left.
fn commit_log_suffix(&self) -> String {
pub(crate) fn commit_log_suffix(&self) -> String {
match self {
Self::Reclaimed(bytes) => format!(
" ({:.2} MB dropped by the pre-commit scrub)",