Several plugins, and varlock's own local encryption, acquire something that is single-use, metered, or user-visible before secrets can be read: an interactive unlock, a login that mints a server-side token, a one-time code. Today each varlock process does that acquisition on its own, and a failed acquisition is retried independently by each of them.
Ordinary secret reads do not have this problem. They are idempotent and cheap to retry. The distinction worth drawing is whether redoing the work costs something: a second fingerprint prompt, a second token against a quota, a second attempt with a code the provider has already rejected.
getOrSet already holds a cross-process lock around its producer (withDirLock in packages/varlock/src/lib/cache/cache-store.ts), and since #1068 it can take a TTL derived from the produced value, which is what a server-decided lifetime needs. The primitives are in place; what is missing is using them for auth acquisition, and deciding what happens when the acquisition fails.
Cases
Interactive unlock
varlock's own local encryption is probably the most widely hit, since it is a core feature rather than a plugin opt-in. packages/varlock/src/lib/local-encrypt/builtin-resolver.ts:16-21 batches concurrent varlock() prompts and biometric decrypts into one queue and rejects the rest of the batch when the user cancels. That is a good model for what this issue asks for, and it is in-process only.
1Password documents the same problem at packages/plugins/1password/src/plugin.ts:87-98: without coordination "in a big project this is super awkward because you may need to scan your finger over and over again." Its workaround is a deferred-promise mutex, also in-process, so a Turborepo build or several dev servers can still produce a prompt each. The same comment notes "We don't currently do anything special to handle if the user denies the login" - that denial is the failure case below.
Bitwarden, Dashlane, KeePass have master-password or biometric unlock with the same shape.
Logins that mint a token with a server-decided lifetime
Each of these caches a token with an expiry on the plugin instance, in memory, so every process logs in again:
packages/plugins/hashicorp-vault/src/plugin.ts:126,208-215 - client_token with lease_duration. Vault meters tokens and leases.
packages/plugins/akeyless/src/plugin.ts:137,194 - cached token with expiresIn from the response.
packages/plugins/azure-key-vault/src/plugin.ts:37-44 - access_token with expires_in.
packages/plugins/infisical/src/plugin.ts:123 - OIDC login returning an accessToken.
A lifetime the server decides and you only learn from the response is exactly the shape #1068's TTL callback was added for.
Single-use codes
AWS MFA (origin of this issue). An AssumeRole with a single-use TOTP code. Built in #1044, now closed and parked with no confirmed user. That branch coordinates the success path through getOrSet but its failure cooldown is per-instance, which is what first surfaced the general problem.
Note the core generateOtp() resolver makes this reachable outside any plugin: a code fed to a CLI via exec() is spent the same way.
The failure path is the subtle part
Do not coordinate failures with a blanket "someone failed, everyone hold off" marker. If the first attempt failed for a transient reason and never reached the provider, nothing was consumed and the next caller would have succeeded; blocking it converts a blip into a failure across a whole parallel build.
Hold others off only when the provider actually responded rejecting the attempt - a code it says is invalid or already used, a denied unlock - because then a retry with the same input fails too. Leave transient and network errors free to retry.
Constraints on any solution
- A shared store is required. Cross-process coordination rides on the disk cache. Under
@cache=memory, @cache=disabled, or --skip-cache there is no shared store and each process is on its own. The auto policy also selects memory in CI and when local encryption is on its file fallback, so the coordination is unavailable in exactly those environments. Whatever is built has to degrade to today's behavior there rather than assume the cache is present.
- Possible circularity, worth checking early. The disk cache decrypts through the same local-encryption backend as biometric decrypts (
cache-store.ts:352-354). Using it to avoid biometric prompts may route through the enclave itself. Whether that actually prompts depends on the backend's session behavior, and should be established before designing around it.
Acceptance criteria
- Two concurrent varlock processes needing the same credential perform the acquisition once, and the second reuses it.
- When the acquisition fails because the provider rejected it, other callers in the same window do not repeat it.
- When it fails transiently, the next caller may still attempt.
- Behavior is unchanged when no shared cache is available.
- Covered by a test with real child processes.
packages/varlock/src/lib/cache/cache-lock-crossproc.test.ts already spawns them and has the temp-HOME harness; its existing tests cover lock lifecycle (steal on dead owner, release on SIGINT, respect a live owner) but not the sharing outcome.
Worth confirming first
The cross-process prompt storm is inferred from the code, not from a user report. Running two varlock loads concurrently against a local-encrypted or 1Password-backed schema and counting prompts would establish whether this is a real annoyance or a theoretical one, and is worth doing before investing in the fix.
Several plugins, and varlock's own local encryption, acquire something that is single-use, metered, or user-visible before secrets can be read: an interactive unlock, a login that mints a server-side token, a one-time code. Today each varlock process does that acquisition on its own, and a failed acquisition is retried independently by each of them.
Ordinary secret reads do not have this problem. They are idempotent and cheap to retry. The distinction worth drawing is whether redoing the work costs something: a second fingerprint prompt, a second token against a quota, a second attempt with a code the provider has already rejected.
getOrSetalready holds a cross-process lock around its producer (withDirLockinpackages/varlock/src/lib/cache/cache-store.ts), and since #1068 it can take a TTL derived from the produced value, which is what a server-decided lifetime needs. The primitives are in place; what is missing is using them for auth acquisition, and deciding what happens when the acquisition fails.Cases
Interactive unlock
varlock's own local encryption is probably the most widely hit, since it is a core feature rather than a plugin opt-in.
packages/varlock/src/lib/local-encrypt/builtin-resolver.ts:16-21batches concurrentvarlock()prompts and biometric decrypts into one queue and rejects the rest of the batch when the user cancels. That is a good model for what this issue asks for, and it is in-process only.1Password documents the same problem at
packages/plugins/1password/src/plugin.ts:87-98: without coordination "in a big project this is super awkward because you may need to scan your finger over and over again." Its workaround is a deferred-promise mutex, also in-process, so a Turborepo build or several dev servers can still produce a prompt each. The same comment notes "We don't currently do anything special to handle if the user denies the login" - that denial is the failure case below.Bitwarden, Dashlane, KeePass have master-password or biometric unlock with the same shape.
Logins that mint a token with a server-decided lifetime
Each of these caches a token with an expiry on the plugin instance, in memory, so every process logs in again:
packages/plugins/hashicorp-vault/src/plugin.ts:126,208-215-client_tokenwithlease_duration. Vault meters tokens and leases.packages/plugins/akeyless/src/plugin.ts:137,194- cached token withexpiresInfrom the response.packages/plugins/azure-key-vault/src/plugin.ts:37-44-access_tokenwithexpires_in.packages/plugins/infisical/src/plugin.ts:123- OIDC login returning anaccessToken.A lifetime the server decides and you only learn from the response is exactly the shape #1068's TTL callback was added for.
Single-use codes
AWS MFA (origin of this issue). An
AssumeRolewith a single-use TOTP code. Built in #1044, now closed and parked with no confirmed user. That branch coordinates the success path throughgetOrSetbut its failure cooldown is per-instance, which is what first surfaced the general problem.Note the core
generateOtp()resolver makes this reachable outside any plugin: a code fed to a CLI viaexec()is spent the same way.The failure path is the subtle part
Do not coordinate failures with a blanket "someone failed, everyone hold off" marker. If the first attempt failed for a transient reason and never reached the provider, nothing was consumed and the next caller would have succeeded; blocking it converts a blip into a failure across a whole parallel build.
Hold others off only when the provider actually responded rejecting the attempt - a code it says is invalid or already used, a denied unlock - because then a retry with the same input fails too. Leave transient and network errors free to retry.
Constraints on any solution
@cache=memory,@cache=disabled, or--skip-cachethere is no shared store and each process is on its own. The auto policy also selects memory in CI and when local encryption is on its file fallback, so the coordination is unavailable in exactly those environments. Whatever is built has to degrade to today's behavior there rather than assume the cache is present.cache-store.ts:352-354). Using it to avoid biometric prompts may route through the enclave itself. Whether that actually prompts depends on the backend's session behavior, and should be established before designing around it.Acceptance criteria
packages/varlock/src/lib/cache/cache-lock-crossproc.test.tsalready spawns them and has the temp-HOME harness; its existing tests cover lock lifecycle (steal on dead owner, release on SIGINT, respect a live owner) but not the sharing outcome.Worth confirming first
The cross-process prompt storm is inferred from the code, not from a user report. Running two varlock loads concurrently against a local-encrypted or 1Password-backed schema and counting prompts would establish whether this is a real annoyance or a theoretical one, and is worth doing before investing in the fix.