fix: construct AsyncEntry inside the try so keychain degradation engages (#1848) - #1945
fix: construct AsyncEntry inside the try so keychain degradation engages (#1848)#1945cliffhall wants to merge 2 commits into
Conversation
`KeyringSecretStore` built its `AsyncEntry` outside the `try` in `get`, `set`, and `delete`. `AsyncEntry::new` performs the platform-store setup (on Linux, the Secret Service connect with a keyutils fallback) and throws when no backend is reachable, so the documented degradation contract never engaged for a construction-time failure: the raw keyring error escaped, 500ing every `GET /api/servers` on a box without a Secret Service (the published container, which has no D-Bus session). `expectedSecretFields` always includes the OAuth slot, so `rehydrateConfig` constructs an entry for every server — the default seeded catalog was enough to 500 the first list load, before any server was added. And because the escaping error wasn't a `KeychainUnavailableError`, it bypassed both the routes' 503 translation and the `migratePlaintextSecrets` skip branch, yielding a generic 500 instead of the actionable message. Moving construction inside the existing `try` restores the contract for that failure mode: `get` returns null, `delete` no-ops, and `set` is the only operation that throws — as `KeychainUnavailableError`, which is what the 503 translation and the migration skip both match on. The test stub's constructor could not fail, which is why the coverage gate never saw the gap. Adds a `constructorThrows` hook alongside the existing method-level `failures` flags and covers all three methods plus `deleteAllForServer` under it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F
`expectedSecretFields` always includes the OAuth slot, and #1848's report frames that as why the 500 fired so broadly — which invites a follow-up that skips the keychain read for servers with "no OAuth config". That change would lose data. `extractSecretsFromStored` deletes the `oauth` block outright when `clientSecret` was its only property, so such a server carries NO marker on disk that a secret exists — the keychain is the sole record, and the unconditional slot is what finds it again. Gating the read on a disk-visible `oauth` block would silently stop rehydrating exactly that shape. The existing tests do fail if the slot is made conditional (verified by applying the change: 4 red), but only under names that explain nothing about the consequence — `always lists the OAuth slot first` reads like a tautology worth updating rather than a trap. Add a round-trip case that states the consequence, so the next reader sees the field is load-bearing rather than defensive. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YAt8rqxysNbhYWLhoRm3fU
|
Picking this up from the session that opened it. The fix itself is sound and I haven't touched it — two additions plus one correction. Container verification: before/after against the real imageThe PR had no container evidence of its own, so I built both images (the "before" one by reverting That's the reporters' error text verbatim, including the unwrapped keyring message that proves it escaped the Added a regression test, because this PR describes a trapThe body repeats the reporter's second finding — that
The existing tests do go red if the slot is made conditional — I verified by actually applying the optimization, 4 fail — but under names that explain nothing about the consequence ( So: the second finding should be declined, not deferred. The pointless-lookup complaint was a symptom of the 500 — with this fix, Correction on the gate
Worth stating precisely, because it bit me on a sibling PR: I ran the remaining stages explicitly on this branch, and they do pass:
I also independently reproduced the two rejections on a clean |
Code reviewReviewed the full diff ( OverviewTwo commits:
CorrectnessThe fix is right, and the reasoning holds up under checking:
No correctness objections. Suggestions1. The source comment at That's the load-bearing reason, and it currently lives only in a test comment. A future reader eyeing "skip the keychain read when there's no oauth block" as an optimization will read the source, not the test. Worth lifting one sentence up into the function's doc comment. 2. N swallowed native throws per GET on a keychain-less box (follow-up, not a change request)
Worth noting the fix is not simply "cache the unavailability" as #1948 does for the module load. Module availability can't change mid-process; keychain availability can — the class comment explicitly says the user "can install it without restarting." A permanent negative cache would break that. If this turns out to matter, the shape is a short TTL or a negative cache invalidated on a successful 3. Pre-existing hazard worth capturing in #1950 — silent Not introduced by this PR, and I confirmed it isn't widened by it, but the diff draws attention to the area:
This PR doesn't make it worse: a construction-level failure fails Test coverageGood, and the test-side half is the more valuable half — the gap survived a ≥90% per-file gate purely because the stub constructor couldn't fail, which is a nice illustration that coverage percentage isn't the same as coverage of failure modes.
Two trivial nits, neither worth blocking on:
Conventions, performance, security
VerdictShip it. The three points above are a docs tweak, a measure-first follow-up, and an item for #1950 — none blocks the merge. Note: authored the first commit, so treat this as a self-review of that half; the |
Closes #1848
Also fixes #1845, #1918, and #1931 — all three closed as duplicates of #1848, all reporting the same
Failed to read server list: Couldn't access platform storage: PermissionDeniedfrom a container with no D-Bus session:ghcr.io/modelcontextprotocol/inspector@modelcontextprotocol/inspector@2.0.0,node:24-alpineexpectedSecretFieldsamplification and the constructor sitting outside thetry.ghcr.io/…:2.1.0, Docker SwarmPOSTsucceeds →409on re-add → empty UI symptom chain.Four separate reporters, three of whom independently identified the same line. That is the argument for the test-side half of this PR: the gap survived a ≥90% per-file gate because the stub constructor could not fail.
KeyringSecretStoreconstructed itsAsyncEntryoutside thetryinget,set, anddelete.AsyncEntry::newperforms the platform-store setup (on Linux, the Secret Service connect with a keyutils fallback) and returns aResult, so it throws when no backend is reachable — and the degradation contract documented atsecret-store.ts:79-89never engaged for that failure. The raw keyring error escaped instead, 500ing everyGET /api/serverson a box without a Secret Service (the published container, which has no D-Bus session).Two consequences beyond the 500 itself, both from the reporter's analysis and both confirmed here:
expectedSecretFieldsalways includes the OAuth slot, sorehydrateConfigconstructs an entry for every server. The default seeded catalog is enough to 500 the first list load, before any server is added.KeychainUnavailableError, so it bypasses both the routes' 503 translation (core/mcp/remote/node/server.ts:1784,:1874) and themigratePlaintextSecretsskip branch — a generic 500 instead of the actionable message.The change
Construction moved inside the existing
tryin all three methods, so the documented contract holds for a construction-time failure:getreturnsnull,deletesilently no-ops, andsetis the only operation that throws — asKeychainUnavailableError. The class doc comment now states that the placement is deliberate and why; thedeletecatch comment covers the constructor as a third throw source.On the judgment call the reporter flagged
Having
setwrap a constructor failure asKeychainUnavailableErroris what makes the 503 translation and the migration-skip branch fire, and it matches the documented contract. Both consumers (core/mcp/remote/node/server.ts,core/client/node-persistence.ts:61) match on the type withinstanceof, and nothing in the tree matches on the raw keyring message — so the typed error is strictly what makes the intended behavior reachable.Tests
The
vi.mockstub constructor could not fail, which is exactly why the coverage gate never saw this gap. Added aconstructorThrowshook alongside the existing method-levelfailuresflags, plus a nested describe coveringget/set/deleteunder it anddeleteAllForServerin the case where the credential sweep succeeds but per-entry construction fails.Verified red-without / green-with: all four new tests fail against the pre-fix
secret-store.tsand pass after.Verification
npm run cigreen, run in stages:validate— passcoverage— 4812 passed;secret-store.tsat 97.5 / 94.44 / 100 / 100 (lines/branches/functions/statements). The run also surfaces two pre-existing unhandled rejections ininspectorClient.test.ts(teardowndisconnectraces) that reproduce identically in a sibling worktree on unmodified code whose CI is green — local flake, untouched by this change.verify:build-gate— passsmoke— all five pass, includingsmoke:web:appci:storybook— 462 passedNo UI change, so no screenshots.
Not fixed by this: #1905
#1905 (Android/Termux) is a module-load failure, one layer earlier: there is no
@napi-rs/keyring-android-arm64binary, so the staticimport { AsyncEntry, findCredentialsAsync } from "@napi-rs/keyring"atcore/auth/node/secret-store.ts:16throws during module evaluation and noKeyringSecretStoremethod ever runs. That needs the import itself made lazy/guarded and funnelled into this same contract — a separate change, for which theconstructorThrowshook added here is the natural place to add amoduleLoadThrowssibling.🤖 Generated with Claude Code
https://claude.ai/code/session_01XAR2Nr9kXbrywFNUVoTe9F