The lock finding from this round is deliberately not addressed here — see below. - **My round-6 strict-read fix was inert in production.** `DeferredSecretStore` is what `defaultSecretStore()` returns, and it did not forward `getStrict` — so `secretStoreGetStrict` fell through to the tolerant `get` for every real caller, and the strictness existed only in tests that injected a concrete store. Forwarded now, along with `getMany`. - **The same tolerant-read bug remained in both pre-existing migrations.** I added `getStrict` for the new hand-off in round 6 and never applied it to the mcp.json and client.json migrations, which have the identical shape: decide from `get`, then write on `null`. A transient keychain read failure therefore let an older plaintext value overwrite a newer keychain one — and the disk copy was then stripped, so the newer value was gone. Both use the strict read now; the throw is a `SecretStoreUnavailableError`, which both call sites already handle as "abandon the migration, keep the file". - **The permission check made a false statement on Windows.** `stat` returns synthetic bits there, and interpreting them yields a confident falsehood either way — "0666 — not owner-only" about a file the ACL may restrict, or "owner-only" about one it does not. The ACL is the real answer and this does not inspect it, so Windows now reports `unknown`. - **Rehydration cost one full decryption per field.** `get` reads and decrypts the entire file, and both rehydration paths ask field by field, serialized behind the store's own queue. Measured on this machine, scrypt at N=16384 is ~23ms per derivation, so a 20-secret encrypted catalog spent **~450ms of pure key derivation on every `GET /api/servers`** — a visible stall, not a micro-optimization. Added an optional `getMany` seam: `FileSecretStore` decrypts once per server, everything else falls back to the parallel per-field `get` that already suited it. A test counts derivations directly: 1 for a three-field `getMany`, 3 for the three `get`s it replaced. Also broadens a log message that still said "Keychain unavailable" for a store that may not be a keychain. Full `npm run ci` green — 5,762 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com>
29 lines
1.1 KiB
TypeScript
29 lines
1.1 KiB
TypeScript
import {
|
|
secretStoreGetMany,
|
|
type SecretStore,
|
|
} from "../../auth/node/secret-store.js";
|
|
import { expectedSecretFields, mergeSecretsIntoStored } from "../serverList.js";
|
|
import type { MCPConfig } from "../types.js";
|
|
|
|
/**
|
|
* Merge per-server secrets from the OS keychain into an on-disk MCP catalog
|
|
* shape. Mirrors the web `/api/servers` GET rehydration path so TUI/CLI see
|
|
* the same effective OAuth client secrets and stdio env values as the browser.
|
|
*/
|
|
export async function rehydrateMcpConfigFromKeychain(
|
|
config: MCPConfig,
|
|
secretStore: SecretStore,
|
|
): Promise<MCPConfig> {
|
|
const out: MCPConfig = { mcpServers: {} };
|
|
await Promise.all(
|
|
Object.entries(config.mcpServers).map(async ([id, stored]) => {
|
|
const fields = expectedSecretFields(stored);
|
|
// One pass per server, not one per field. For the keychain this is the
|
|
// same parallel round-trips as before; for the file store it collapses
|
|
// N reads-and-decrypts into one (see `secretStoreGetMany`).
|
|
const secrets = await secretStoreGetMany(secretStore, id, fields);
|
|
out.mcpServers[id] = mergeSecretsIntoStored(stored, secrets);
|
|
}),
|
|
);
|
|
return out;
|
|
}
|