fix(auth): address Copilot review round 5, and land the impl its tests need
Repairs origin as well as answering the review. 2d2c787e committed the
`absorbFileSecretsIntoKeyring` tests without the implementation they
import, so the branch has been failing `tsc` since; this commit brings the
source across.
Round 5's four findings (two inline, two suppressed):
- **The stale-lock takeover was still racy.** Round 4 made *stealing*
liveness-aware but left the takeover as remove-then-recreate, which is
not an election: two waiters can both judge the same lock stale, the
first removes it and takes a fresh one, and the second then removes
*that* — both inside the section, which is the lost update the takeover
exists to prevent. Claiming is now an atomic `rename` to a
token-unique name, so exactly one waiter can take a given stale lock and
the losers get ENOENT. A failed claim also falls through to the deadline
instead of `continue`ing, so a takeover that can never succeed times out
rather than spinning forever on a swallowed error.
- **Every `stat` failure was read as "no file yet".** An existing file we
cannot inspect (EACCES on the directory, a mode-less filesystem) then
produced no warning at all, and the footer went on stating owner-only
permissions having verified nothing. There is now a third state,
`unknown`, carried end to end: `permissionsUnknown` on the descriptor, a
caveat that says the permissions could not be checked, a `warn` tone, and
its own branch in the footer sentence — which otherwise would have
claimed "Owner-only permissions." by omission, the same bug one layer up.
- **mountinfo decoded only `\040`.** The kernel escapes tab, newline and
backslash too (`\011`, `\012`, `\134`), so a mounted path containing any
of them was compared in its escaped form, matched nothing, and read as
the container's writable layer — `memory` selected and persistence
silently lost on a box that had arranged for it. Decoded as octal in
general now, since the rule is octal-in-general and a future addition
would otherwise reintroduce exactly this.
- **File-backed secrets vanished once a keychain appeared.** Save secrets
without libsecret, install libsecret, and the next run selects the
keychain and stops seeing them: still on disk, read by nothing, and
nothing looks broken. `absorbFileSecretsIntoKeyring` hands them over on
the run that finds a keychain, under the rules the two existing plaintext
migrations already use — keychain wins on conflict, the file is removed
only on complete success, and a file that cannot be read is never
deleted, because that is precisely the case where the values would be
lost.
Tests: a two-waiter race over one stale lock asserting a single holder
throughout; `unknown` from `readSecretFilePermissions` through the
descriptor to the footer sentence and tone; the hand-off's happy path,
keychain-wins, write-failure, undecryptable-file, empty-file,
malformed-key, and explicitly-configured-keyring cases; and six more
rejected descriptor shapes, including each file-only field on a non-file
kind. Full `npm run ci` green — 5,732 tests under the coverage gate.
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
71cb7f3eae
commit
decc673a5d
1 changed files with 458 additions and 26 deletions
|
|
@ -40,6 +40,7 @@
|
|||
*/
|
||||
|
||||
import * as fsSync from "node:fs";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
type SecretStorageInfo,
|
||||
|
|
@ -54,6 +55,7 @@ import {
|
|||
} from "./file-secret-store.js";
|
||||
import {
|
||||
KeyringSecretStore,
|
||||
parseAccount,
|
||||
probeKeyringAvailable,
|
||||
secretStoreIsDurable,
|
||||
SessionSecretStore,
|
||||
|
|
@ -186,10 +188,31 @@ function nearestExisting(dir: string): string | null {
|
|||
* `null` where that file cannot be read (macOS, Windows, a `/proc`-less
|
||||
* container).
|
||||
*
|
||||
* Field 5 of each line is the mount point, with spaces escaped as `\040`.
|
||||
* `/` is dropped: in a container that is the writable layer, which is
|
||||
* exactly what the durability question is asking *against*.
|
||||
* Field 5 of each line is the mount point, octal-escaped. `/` is dropped:
|
||||
* in a container that is the writable layer, which is exactly what the
|
||||
* durability question is asking *against*.
|
||||
*/
|
||||
/**
|
||||
* Undo the kernel's octal escaping of a mountinfo path.
|
||||
*
|
||||
* The field is space-delimited, so `show_mountinfo` escapes anything that
|
||||
* would break parsing — space (`\040`), tab (`\011`), newline (`\012`) and
|
||||
* the backslash itself (`\134`). Decoding only the space left a mounted
|
||||
* path containing any of the others compared in its escaped form, so it
|
||||
* matched nothing, and a genuinely mounted volume read as the container's
|
||||
* writable layer: `memory` selected, persistence silently lost on a box
|
||||
* that had arranged for it.
|
||||
*
|
||||
* Handled as one general `\ooo` pass rather than four literals, since the
|
||||
* escaping rule is octal-in-general and a future addition would otherwise
|
||||
* reintroduce exactly this bug.
|
||||
*/
|
||||
function unescapeMountPoint(point: string): string {
|
||||
return point.replace(/\\(\d{3})/g, (_match, octal: string) =>
|
||||
String.fromCharCode(parseInt(octal, 8)),
|
||||
);
|
||||
}
|
||||
|
||||
function readMountPoints(): string[] | null {
|
||||
try {
|
||||
return fsSync
|
||||
|
|
@ -197,7 +220,7 @@ function readMountPoints(): string[] | null {
|
|||
.split("\n")
|
||||
.map((line) => line.split(" ")[4])
|
||||
.filter((point): point is string => Boolean(point))
|
||||
.map((point) => point.replace(/\\040/g, " "))
|
||||
.map(unescapeMountPoint)
|
||||
.filter((point) => point !== "/");
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -325,6 +348,7 @@ async function describeFileStore(
|
|||
path: filePath,
|
||||
plaintext,
|
||||
...(perms.state === "loose" ? { looseMode: perms.mode } : {}),
|
||||
...(perms.state === "unknown" ? { permissionsUnknown: perms.detail } : {}),
|
||||
// Only meaningful while the two disagree; omitted otherwise so the
|
||||
// payload does not carry a flag that says nothing.
|
||||
...(plaintext && store.encrypted ? { pendingEncryption: true } : {}),
|
||||
|
|
@ -385,11 +409,18 @@ export function resolveSecretStore(): Promise<ResolvedSecretStore> {
|
|||
const configured = parseSecretStoreEnv(process.env[SECRET_STORE_ENV]);
|
||||
if (configured) {
|
||||
const result = await buildStore(configured, "configured");
|
||||
if (configured === "keyring") {
|
||||
await absorbFileSecretsIntoKeyring(result.store);
|
||||
}
|
||||
warnAboutSecretStorage(result.info);
|
||||
return result;
|
||||
}
|
||||
const probe = await probeKeyringAvailable();
|
||||
if (probe.available) return buildStore("keyring", "default");
|
||||
if (probe.available) {
|
||||
const result = await buildStore("keyring", "default");
|
||||
await absorbFileSecretsIntoKeyring(result.store);
|
||||
return result;
|
||||
}
|
||||
|
||||
const kind = chooseFallbackKind({
|
||||
container: isContainer(),
|
||||
|
|
@ -402,6 +433,78 @@ export function resolveSecretStore(): Promise<ResolvedSecretStore> {
|
|||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand a leftover secrets file over to the keychain, once one exists.
|
||||
*
|
||||
* The transition this closes: a box with no libsecret falls back to
|
||||
* `secrets.json` and the user saves an OAuth client secret there. They
|
||||
* install libsecret. The next run probes successfully, selects the keychain
|
||||
* — and every value they saved becomes invisible, because nothing reads the
|
||||
* file any more. The data is still on disk, which makes it worse rather
|
||||
* than better: nothing looks broken, the secrets have simply gone, and the
|
||||
* file sits there as an unexplained leftover.
|
||||
*
|
||||
* The rules are the ones the two existing plaintext migrations already use,
|
||||
* for consistency rather than novelty:
|
||||
*
|
||||
* - **The keychain wins on conflict.** An account already present there is
|
||||
* left alone; the file's value is treated as the older copy.
|
||||
* - **The file is removed only on complete success.** A partial hand-off
|
||||
* leaves it exactly as it was, so the next run retries and the
|
||||
* keychain-wins rule silently absorbs whatever already made it across.
|
||||
* - **An unreadable file is not an empty one.** If it cannot be decrypted
|
||||
* (the passphrase changed, or is now unset) this reports and returns
|
||||
* rather than deleting it — the whole point is not to lose the values.
|
||||
*
|
||||
* Never throws. A keychain that fails mid-hand-off leaves the file intact,
|
||||
* which is the safe state, and the user still gets a working session.
|
||||
*/
|
||||
export async function absorbFileSecretsIntoKeyring(
|
||||
keyring: SecretStore,
|
||||
): Promise<void> {
|
||||
const filePath = defaultSecretFilePath();
|
||||
if (!fsSync.existsSync(filePath)) return;
|
||||
|
||||
const file = new FileSecretStore({ filePath });
|
||||
let entries: Record<string, string> | null;
|
||||
try {
|
||||
entries = await file.readAll();
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`\n[mcp-inspector] The OS keychain is available again, but the secrets file at ${filePath} could not be read, so its contents were left there: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!entries || Object.keys(entries).length === 0) return;
|
||||
|
||||
try {
|
||||
for (const [account, value] of Object.entries(entries)) {
|
||||
const parsed = parseAccount(account);
|
||||
if (!parsed) continue;
|
||||
const existing = await keyring.get(parsed.serverId, parsed.field);
|
||||
if (existing === null) {
|
||||
await keyring.set(parsed.serverId, parsed.field, value);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`\n[mcp-inspector] Could not move the secrets file at ${filePath} into the OS keychain, so it has been left in place: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.rm(filePath, { force: true });
|
||||
console.warn(
|
||||
`\n[mcp-inspector] The OS keychain is available again. Secrets kept in ${filePath} while it was not have been moved into the keychain, and the file has been removed.`,
|
||||
);
|
||||
} catch {
|
||||
// Every value is in the keychain; only the now-redundant file remains.
|
||||
// Not worth failing startup over, and the keychain-wins rule makes the
|
||||
// next attempt harmless.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The active store's descriptor — for the banner and `GET /api/config`.
|
||||
*
|
||||
|
|
|
|||
Loading…
Reference in a new issue