fix(auth): address Copilot review round 3 on the secret-store PR
Nine findings (six inline, three suppressed), all taken. Two were serious. **The memory fallback made the migrations destructive.** Both migrations — mcp.json in the remote server, client.json in `node-persistence` — lift a plaintext secret off disk, write it to the secret store, and then delete it from the file. That trade is safe only while the store is at least as durable as the disk, which held by construction while `InMemorySecretStore` was a test double. Making it a production fallback broke it: on an unmounted container a plain `GET /api/servers` would have moved a user's existing secrets into RAM and lost them at exit — destroying data on a read. `SecretStore` gains an optional `isDurable()`, absent meaning durable, and both migrations withhold the disk delete when it answers false (the value is still loaded, so the session behaves normally). The production fallback is now a named `SessionSecretStore` rather than the bare `InMemorySecretStore`, so the suite's many doubles keep standing in for a *working keychain* — which is what they are there to be — and only code that deliberately chooses RAM in production says so. **Round 2's per-call descriptor never reached anyone.** Both web servers resolved it once at startup and baked it into `initialConfig`, which `/api/config` serves unchanged — so the freshness fix was inert for its only consumer. The route now takes a `secretStorageResolver` and calls it per request. The lock needed three more fixes, and they compound: - **Release removed whatever directory was at the path.** A holder whose work outran the stale threshold would delete the *next* owner's lock, letting a third writer into a section two processes believed they held. Ownership is now stamped with a random token and release only removes a lock still carrying it. - **The timeout fell through to an unlocked write** — under contention, the one moment another writer is provably active. It now throws `SecretFileLockTimeoutError`, which `set` is allowed to do. - **The first write of every process bypassed the lock**, because the secrets directory does not exist yet and `mkdir` failed ENOENT. The parent is created first. First writes are exactly when two processes are most likely to start together. Fixing the timeout exposed a fourth: with stale (10s) below timeout, a waiter stole from a holder that was merely *slow* rather than dead — reinstating the lost update. A holder now heartbeats its lock, so "stale" means the holder is gone, and the timeout is what a live-but-slow holder produces. Lock timings are injectable so the tests can exercise contention, staleness, and timeout without spending the real intervals. Also: the loose-permission state is carried into `SecretStorageInfo` as `looseMode` and rendered as the caveat (it outranks the encryption caveat — a file others can read is a live exposure, while "unencrypted" is a property of a file only its owner can open), rather than living in a startup log the browser cannot see; and `usableSecretStorage` validates `pendingEncryption` and `looseMode` as the optional booleans/numbers they are (`pendingEncryption: "false"` is truthy and would have printed advice that is the opposite of the truth) and rejects file-only fields on a keychain or memory descriptor. Tests: both migrations against a session store with a durable control alongside, so the guard is visibly the difference; `/api/config` serving a changing resolver and falling back without one; lock ownership after a steal, the timeout under a live holder, no-steal from a slow holder, and a locked first write; `looseMode` through the descriptor, the caveat ordering, and the tone; and five more rejected payload shapes. Full `npm run ci` green — 5,704 tests under the coverage gate. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
de7795ca54
commit
b143dd6cbd
1 changed files with 674 additions and 36 deletions
|
|
@ -65,6 +65,7 @@ import { RemoteSession } from "./remote-session.js";
|
|||
import { createRemoteAuthProvider } from "./tokenAuthProvider.js";
|
||||
import { API_SERVER_ENV_VARS } from "../constants.js";
|
||||
import {
|
||||
secretStoreIsDurable,
|
||||
SecretStoreUnavailableError,
|
||||
type SecretStore,
|
||||
} from "../../../auth/node/secret-store.js";
|
||||
|
|
@ -260,6 +261,15 @@ export interface RemoteServerOptions {
|
|||
* runners.
|
||||
*/
|
||||
secretStore?: SecretStore;
|
||||
/**
|
||||
* Re-resolve the secret-storage descriptor for each `GET /api/config`.
|
||||
*
|
||||
* A function rather than a value because the answer changes while the
|
||||
* process runs (see the route). Optional: a backend that doesn't supply
|
||||
* one falls back to whatever `initialConfig` carried, which is what the
|
||||
* tests and any embedder that doesn't care get.
|
||||
*/
|
||||
secretStorageResolver?: () => Promise<SecretStorageInfo | undefined>;
|
||||
}
|
||||
|
||||
export interface CreateRemoteAppResult {
|
||||
|
|
@ -662,10 +672,22 @@ export function createRemoteApp(
|
|||
app.use("*", createAuthMiddleware(authToken));
|
||||
}
|
||||
|
||||
app.get("/api/config", (c) => {
|
||||
app.get("/api/config", async (c) => {
|
||||
// `secretStorage` is re-resolved per request, not taken from the
|
||||
// startup payload. It describes bytes on disk that this very process
|
||||
// changes — the first `set` under a newly-set passphrase encrypts a
|
||||
// pre-existing plaintext file — so a value captured at boot would keep
|
||||
// telling every page load "still unencrypted" until a restart. That is
|
||||
// stale in the safe direction, but stale about the single fact this
|
||||
// field exists to state, which makes re-reading a small file per config
|
||||
// fetch (once per page load) the obviously right trade.
|
||||
const secretStorage = options.secretStorageResolver
|
||||
? await options.secretStorageResolver()
|
||||
: options.initialConfig?.secretStorage;
|
||||
const payload = {
|
||||
...options.initialConfig,
|
||||
writable,
|
||||
...(secretStorage ? { secretStorage } : {}),
|
||||
...(options.sandboxUrl ? { sandboxUrl: options.sandboxUrl } : {}),
|
||||
};
|
||||
return c.json(payload);
|
||||
|
|
@ -1918,6 +1940,12 @@ export function createRemoteApp(
|
|||
): Promise<{ migrated: MCPConfig; changed: boolean }> => {
|
||||
let changed = false;
|
||||
const next: MCPConfig = { mcpServers: {} };
|
||||
const durable = await secretStoreIsDurable(secretStore);
|
||||
if (!durable && fileLogger) {
|
||||
fileLogger.warn(
|
||||
"Secrets are kept in memory for this session, so plaintext values in mcp.json are left on disk rather than migrated away. They would otherwise be lost when the Inspector exits.",
|
||||
);
|
||||
}
|
||||
try {
|
||||
for (const [id, stored] of Object.entries(config.mcpServers)) {
|
||||
const { stripped, secrets } = extractSecretsFromStored(stored);
|
||||
|
|
@ -1935,6 +1963,17 @@ export function createRemoteApp(
|
|||
// plaintext from disk. This handles the case where a user has
|
||||
// edited mcp.json by hand after the original migration.
|
||||
}
|
||||
// Only strip the plaintext once it is somewhere that outlives us.
|
||||
// Against a session-scoped store (the container fallback added in
|
||||
// #1950) this would trade a secret that survives restarts for one
|
||||
// that dies with the process — and it runs on an ordinary GET, so
|
||||
// merely opening the app would destroy it. The values are still
|
||||
// loaded into the store above, so this session behaves normally;
|
||||
// only the disk delete is withheld.
|
||||
if (!durable) {
|
||||
next.mcpServers[id] = stored;
|
||||
continue;
|
||||
}
|
||||
next.mcpServers[id] = stripped;
|
||||
changed = true;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue