fix(auth): address Copilot review round 13
No new inline comments this round, but two of the four suppressed ones were real defects rather than restatements — both in code I added, both silently wrong in the safe-looking direction. **A symlinked secrets directory read as unmounted.** `isOnMountPoint` walks to the nearest existing ancestor *lexically* and then string-prefixes it against mountinfo, so `~/.mcp-inspector -> /data/inspector` with `/data` mounted matched no mount point. The container then selected the session-only store on a box that had explicitly arranged for persistence, and nothing said so — the mount was there, the volume was there, the secrets evaporated on exit. Resolved through symlinks now, tolerantly: a path that cannot be resolved falls back to the lexical comparison, which is what this did before and is correct whenever no symlink is involved. The `statSync` fallback branch already followed links, so this also makes the two paths agree. **The bulk read did not fix the case it was written for.** `getMany` batched *one server's* fields, but both rehydration callers iterate servers — so a catalog of 20 servers holding one secret each still paid 20 serialized scrypt derivations. The same ~450ms stall the seam was introduced to remove, reached one server at a time instead of one field at a time. Measuring the wrong unit and then optimizing it is worse than not optimizing, because the number in the commit message says it is fixed. The seam is cross-server now — `getMany(requests: SecretBulkRequest[])` — and both callers pass the whole catalog in one call. `FileSecretStore` decrypts once for the entire rehydration; the keychain fallback still issues independent parallel `get`s, which is what it wants. A test asserts one derivation for four servers, next to the existing one asserting one for three fields, so the property is pinned at both axes. The other two suppressed comments are the cross-process guarantee — `mutate`'s residual and the hand-off's TOCTOU delete — which remain decided against for this PR and tracked in #2082. Full `npm run ci` green. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
bc4b774d80
commit
26d2f58784
1 changed files with 265 additions and 74 deletions
|
|
@ -300,6 +300,12 @@ const PROBE_ACCOUNT = "__inspector:probe";
|
|||
* routes turn into a 503 — because it is the only one where a value would
|
||||
* be lost.
|
||||
*/
|
||||
/** One server's worth of a bulk read: which fields, for which server id. */
|
||||
export interface SecretBulkRequest {
|
||||
serverId: string;
|
||||
fields: string[];
|
||||
}
|
||||
|
||||
export interface SecretStore {
|
||||
/**
|
||||
* Do values written here outlive the process?
|
||||
|
|
@ -338,19 +344,27 @@ export interface SecretStore {
|
|||
*/
|
||||
getStrict?(serverId: string, field: string): Promise<string | null>;
|
||||
/**
|
||||
* Read several of one server's fields in a single pass.
|
||||
* Read many fields, across **many servers**, in a single pass.
|
||||
*
|
||||
* Optional, and purely a performance seam: {@link secretStoreGetMany}
|
||||
* falls back to a parallel `get` per field, which is what the keychain
|
||||
* wants anyway (independent native round-trips).
|
||||
*
|
||||
* It exists for `FileSecretStore`, where the shape is inverted — every
|
||||
* `get` reads and decrypts the *whole* file, so N fields cost N scrypt
|
||||
* derivations, serialized behind the store's own queue. The rehydration
|
||||
* callers ask for one server's fields at a time, so a per-server bulk read
|
||||
* turns that back into one.
|
||||
* `get` reads and decrypts the *whole* file, so each call costs a scrypt
|
||||
* derivation, serialized behind the store's own queue.
|
||||
*
|
||||
* **Cross-server, and that is the whole point.** A per-server version of
|
||||
* this shipped first and did not fix the case it was written for: both
|
||||
* rehydration callers iterate servers, so a catalog of 20 servers holding
|
||||
* one secret each still paid 20 serialized derivations — the same stall,
|
||||
* reached one server at a time instead of one field at a time. The unit
|
||||
* that matters is "everything this rehydration needs", so that is the
|
||||
* unit the seam takes.
|
||||
*/
|
||||
getMany?(serverId: string, fields: string[]): Promise<Record<string, string>>;
|
||||
getMany?(
|
||||
requests: SecretBulkRequest[],
|
||||
): Promise<Record<string, Record<string, string>>>;
|
||||
set(serverId: string, field: string, value: string): Promise<void>;
|
||||
/** No-op if no entry exists. */
|
||||
delete(serverId: string, field: string): Promise<void>;
|
||||
|
|
@ -541,25 +555,34 @@ export async function secretStoreGetStrict(
|
|||
}
|
||||
|
||||
/**
|
||||
* Read several fields of one server, using the store's bulk path when it has
|
||||
* one. Returns only the fields that are present, matching the callers that
|
||||
* merge the result into a stored config.
|
||||
* Read many servers' fields, using the store's bulk path when it has one.
|
||||
*
|
||||
* Returns a map keyed by server id, holding only the fields that are
|
||||
* present — matching the callers, which merge each server's result into its
|
||||
* stored config. The fallback issues every `get` in parallel, which is right
|
||||
* for a keychain (independent native round-trips) and harmless for the
|
||||
* in-memory store.
|
||||
*/
|
||||
export async function secretStoreGetMany(
|
||||
store: SecretStore,
|
||||
serverId: string,
|
||||
fields: string[],
|
||||
): Promise<Record<string, string>> {
|
||||
if (store.getMany) return store.getMany(serverId, fields);
|
||||
const entries = await Promise.all(
|
||||
fields.map(
|
||||
async (field) => [field, await store.get(serverId, field)] as const,
|
||||
),
|
||||
requests: SecretBulkRequest[],
|
||||
): Promise<Record<string, Record<string, string>>> {
|
||||
if (store.getMany) return store.getMany(requests);
|
||||
const out: Record<string, Record<string, string>> = {};
|
||||
await Promise.all(
|
||||
requests.map(async ({ serverId, fields }) => {
|
||||
const entries = await Promise.all(
|
||||
fields.map(
|
||||
async (field) => [field, await store.get(serverId, field)] as const,
|
||||
),
|
||||
);
|
||||
const found: Record<string, string> = {};
|
||||
for (const [field, value] of entries) {
|
||||
if (value !== null) found[field] = value;
|
||||
}
|
||||
out[serverId] = found;
|
||||
}),
|
||||
);
|
||||
const out: Record<string, string> = {};
|
||||
for (const [field, value] of entries) {
|
||||
if (value !== null) out[field] = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue