Skip to content

Fail a credential lookup when the store stops answering, instead of hanging - #1588

Open
GeiserX wants to merge 3 commits into
UsefulSoftwareCo:mainfrom
GeiserX:fix/bound-credential-provider-call
Open

Fail a credential lookup when the store stops answering, instead of hanging#1588
GeiserX wants to merge 3 commits into
UsefulSoftwareCo:mainfrom
GeiserX:fix/bound-credential-provider-call

Conversation

@GeiserX

@GeiserX GeiserX commented Aug 14, 2026

Copy link
Copy Markdown

A credential provider is frequently remote — the 1Password backend talks to a service over
HTTP, and any custom provider may be a network store — so "stopped answering" is one of its ordinary
failure modes rather than an exotic one.

Worth saying up front, because it is the strongest argument for this change: the 1Password plugin
already bounds every SDK call itself at DEFAULT_TIMEOUT_MS = 15_000. The one remote provider in the
tree found a bound necessary and added its own. Executor does not give that to the providers which do
not, and nothing in the interface tells their authors it is their job.

Nothing bounds the call. Measured with a provider whose get never returns:

seed create resolve
provider hangs ok ok never returns
control provider ok ok ok

So a store that goes away does not fail a tool invocation, it hangs it — and nothing in the
resulting silence names the provider.

Why this looks like a gap rather than a deliberate choice

CredentialProvider documents nothing about timing: no expectation that get returns promptly, no
note that the caller will not bound it. So a provider author has no reason to think they own the
timeout, and Executor does not add one — which is how a remote dependency ends up with no bound at all.

Executor already bounds its other remote calls this way:

  • oauth-discovery.tsEffect.timeoutOrElse
  • packages/plugins/mcp/src/sdk/probe-shape.tsEffect.timeout(Duration.millis(timeoutMs)), in
    several places

Credential resolution is the one remote dependency without a bound.

Shape of the change

Bounded once at the registration funnel rather than at each call site, so every provider passes
through one place on its way in rather than every call site remembering. The wrapper names the five
methods CredentialProvider has today, so adding a sixth means adding it here too — worth a note on
the interface if you would rather that were automatic. Optional methods stay optional — a provider
that cannot enumerate must not appear to. The failure names the provider and the operation, so the
diagnostic points at the store rather than at whatever the caller happened to be doing.

Thirty seconds is a backstop against a dead dependency, not a latency budget; a store legitimately
slower than that is better served by the operator hearing about it than by the request waiting
indefinitely. Happy to make it configurable, or to pick a different number.

Tests

provider-call-timeout.test.ts — the hang now fails, the message names the provider and operation, and
a control provider still resolves. Removing the bound reddens the first two and leaves the control
green. The tests advance a virtual clock past the bound rather than waiting thirty seconds.

Part of the review in #1585; see #1564 for the first of this series.

…ils, not hangs

A credential provider is frequently remote — the 1Password backend talks to a
service over HTTP, and any custom provider may be a network store — so "stopped
answering" is one of its ordinary failure modes rather than an exotic one.
Nothing bounded the call, so a store that went away did not fail a tool
invocation, it hung it, and nothing in the resulting silence named the provider.

Measured before changing anything: with a provider whose `get` never returns,
seeding and connection creation both succeed and the resolution never comes back.
A control provider resolves normally, so the hang is the provider call and not
the harness.

`CredentialProvider` documents nothing about timing — no expectation that `get`
returns promptly, no note that the caller will not bound it — so neither side
owned this. Executor already bounds its other remote calls the same way, in OAuth
discovery and in the MCP plugin's probes; credential resolution was the one that
did not.

Bounded once at the registration funnel rather than at each call site, so a
method added later is bounded by default instead of by whoever remembers.
Optional methods stay optional: a provider that cannot enumerate must not appear
to. The failure names the provider and the operation, so the diagnostic points at
the store rather than at whatever the caller happened to be doing.

Thirty seconds is a backstop against a dead dependency, not a latency budget.
The tests advance a virtual clock past it rather than waiting.
The wrapper destructured the four optional methods and called the bindings
bare, which drops `this`. `get` was already called on the provider, so the
two disagreed. Every provider in the tree is an object literal and cannot
notice; a provider written as a class — which is exactly what "wrap any
provider" invites — threw TypeError on its first optional call.

Covered by a class-based provider test, with an object-literal control that
is identical except for that one difference, so a red result can only mean
the receiver.

Also pins the operation in the message-shape test, which asserted the
provider and the phrasing but not the operation it is named for, and uses
Exit.isFailure rather than inspecting _tag, which the repo's own
no-manual-tag-check rule rejects.
…mbers

The wrapper spread the provider. A spread copies only own ENUMERABLE
properties, so everything on a class's prototype — its methods, and accessors
like `writable` — was dropped silently. Nothing raised: the wrapper simply
appeared not to have the capability, and the caller took a path the provider
meant to own. A class-based provider whose `writable` is an accessor stops
being seen as a writable store at all, so creating a connection from a pasted
value fails with "provider not registered: default".

It now inherits through Object.create and shadows only the five methods it
bounds, which also means a capability added to CredentialProvider later
survives the wrapper without anyone remembering to list it here.

Covered by a class-based provider whose `writable` lives on the prototype,
verified failing before the change with exactly that error.
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