Two changes, interleaved in the same files because both are about the same read-modify-write path. ## The lock is gone (maintainer decision on the round-5/7/8 finding) Three consecutive review rounds found a real race in the `mkdir` lock, and the last one is not patchable with what Node exposes: claiming a stale lock needs compare-and-swap on a directory entry (`renameat2(RENAME_EXCHANGE)`). Without it, `canSteal` inspects one lock but `rename` acts on whatever is at that path *later*, so a waiter that lost the first race can move the winner's fresh lock aside and enter alongside it. Rather than patch it a fourth time, writers now collide and the loser notices. `mutate` reads `M0`, applies the mutation to get `M1`, writes it, then reads back `M2`; if they differ, someone wrote in between, so it re-applies onto what they left and retries, bounded. The comparison is over the **whole map**, and that is the load-bearing detail. Checking only the entry we touched passes in exactly the case that loses data — ours is present, and it is the *other* writer's that is gone. With A and B from the same `M0`: A writes `MA`, B writes `MB` and clobbers A; B verifies and correctly sees `MB`; A verifies, sees `M2 !== MA`, re-applies onto `MB`, writes again. Both survive, and the writer that was clobbered is the one that repairs it. Two things this deliberately does not claim: - It is **not mutual exclusion**. A process that crashes between its write and its verifying read cannot notice it was clobbered, and nothing else will. That window is strictly narrower than the lock's — which lost updates with every participant alive — and needs no primitive Node lacks. Stated in the code rather than left to be rediscovered in review. - Non-convergence **fails loudly**. After five lost rounds `set` throws rather than returning, because returning would reintroduce the silent loss with extra steps. `delete` stays silent, per the interface contract. `serialize` is now keyed on the resolved path **process-wide** rather than per instance. Two `FileSecretStore`s on one file are ordinary — the resolved store holds one and `absorbFileSecretsIntoKeyring` builds another — and a per-instance queue does not order them against each other at all. The new convergence test caught this on its first run: two instances racing `set` lost an entry. In-process is now correct by construction; the verify covers what it cannot see, a second Inspector process. The hand-off's read → copy → delete was the lock's other user. Its delete is now conditional: the file is re-read immediately before removal and compared against what was copied, and it declines to delete when they differ. Leaving the file costs nothing (keychain-wins makes the next run idempotent) while deleting on a stale read costs a secret. ## Round 9 (two inline, two suppressed) - **`KeyringSecretStore.getStrict` let raw binding errors escape.** Both plaintext migrations keep their source file only when they catch `SecretStoreUnavailableError`, so a transient keychain failure 500'd `GET /api/servers` instead of abandoning the migration. Wrapped, as `set` already does. - **`FileSecretStore` had no strict read at all**, so `secretStoreGetStrict` fell back to the tolerant `get` and mapped every read failure to `null` — leaving the exact inversion the seam was introduced to prevent open on the store it was introduced for. - **`readOnDiskEncryption` reported "encrypted" for an envelope it cannot open.** Naming the cipher is not the same as being openable: a file with no `kdf`/`data`, or a `data` that is not `iv.tag.ciphertext`, produced the quiet reassuring footer for a file whose next save was guaranteed to fail. - **`/api/config` kept a stale descriptor when the resolver returned `undefined`**, because the conditional spread left the startup value standing. Assigned unconditionally now; `c.json` omits the key. Full `npm run ci` green. Signed-off-by: cliffhall <cliff@futurescale.com>
167 lines
5.9 KiB
TypeScript
167 lines
5.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { createRemoteApp } from "@inspector/core/mcp/remote/node/server.js";
|
|
|
|
describe("createRemoteApp GET /api/config", () => {
|
|
it("includes sandboxUrl in response when option is set", async () => {
|
|
const sandboxUrl = "http://localhost:9123/sandbox";
|
|
const { app } = createRemoteApp({
|
|
dangerouslyOmitAuth: true,
|
|
allowedOrigins: ["http://127.0.0.1:6274"],
|
|
sandboxUrl,
|
|
initialConfig: { defaultEnvironment: {} },
|
|
});
|
|
const res = await app.request(new Request("http://test/api/config"));
|
|
expect(res.status).toBe(200);
|
|
const data = (await res.json()) as { sandboxUrl?: string };
|
|
expect(data.sandboxUrl).toBe(sandboxUrl);
|
|
});
|
|
|
|
it("omits sandboxUrl when option is not set", async () => {
|
|
const { app } = createRemoteApp({
|
|
dangerouslyOmitAuth: true,
|
|
allowedOrigins: ["http://127.0.0.1:6274"],
|
|
initialConfig: { defaultEnvironment: {} },
|
|
});
|
|
const res = await app.request(new Request("http://test/api/config"));
|
|
expect(res.status).toBe(200);
|
|
const data = (await res.json()) as { sandboxUrl?: string };
|
|
expect(data).not.toHaveProperty("sandboxUrl");
|
|
});
|
|
|
|
it("uses initialConfig when provided instead of env", async () => {
|
|
const { app } = createRemoteApp({
|
|
dangerouslyOmitAuth: true,
|
|
allowedOrigins: ["http://127.0.0.1:6274"],
|
|
initialConfig: {
|
|
defaultCommand: "my-server",
|
|
defaultArgs: ["--foo"],
|
|
defaultTransport: "stdio",
|
|
defaultCwd: "/tmp",
|
|
defaultEnvironment: { PATH: "/usr/bin" },
|
|
},
|
|
});
|
|
const res = await app.request(new Request("http://test/api/config"));
|
|
expect(res.status).toBe(200);
|
|
const data = (await res.json()) as {
|
|
defaultCommand?: string;
|
|
defaultArgs?: string[];
|
|
defaultTransport?: string;
|
|
defaultCwd?: string;
|
|
defaultEnvironment?: Record<string, string>;
|
|
};
|
|
expect(data.defaultCommand).toBe("my-server");
|
|
expect(data.defaultArgs).toEqual(["--foo"]);
|
|
expect(data.defaultTransport).toBe("stdio");
|
|
expect(data.defaultCwd).toBe("/tmp");
|
|
expect(data.defaultEnvironment).toEqual({ PATH: "/usr/bin" });
|
|
});
|
|
|
|
it("reports writable:true by default and writable:false when set", async () => {
|
|
const writableApp = createRemoteApp({
|
|
dangerouslyOmitAuth: true,
|
|
initialConfig: { defaultEnvironment: {} },
|
|
}).app;
|
|
const writableRes = await writableApp.request(
|
|
new Request("http://test/api/config"),
|
|
);
|
|
expect(
|
|
((await writableRes.json()) as { writable?: boolean }).writable,
|
|
).toBe(true);
|
|
|
|
const readOnlyApp = createRemoteApp({
|
|
dangerouslyOmitAuth: true,
|
|
writable: false,
|
|
initialConfig: { defaultEnvironment: {} },
|
|
}).app;
|
|
const readOnlyRes = await readOnlyApp.request(
|
|
new Request("http://test/api/config"),
|
|
);
|
|
expect(
|
|
((await readOnlyRes.json()) as { writable?: boolean }).writable,
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("GET /api/config re-resolves secretStorage per request (#1950)", () => {
|
|
it("serves the resolver's current answer, not the startup snapshot", async () => {
|
|
// The descriptor states where a secret lands, and it changes while the
|
|
// process runs — the first write under a newly-set passphrase encrypts a
|
|
// pre-existing plaintext file. A value captured at boot would keep
|
|
// telling every page load "still unencrypted" until a restart.
|
|
let plaintext = true;
|
|
const { app } = createRemoteApp({
|
|
dangerouslyOmitAuth: true,
|
|
initialConfig: {
|
|
defaultEnvironment: {},
|
|
secretStorage: {
|
|
kind: "file",
|
|
reason: "fallback",
|
|
durable: true,
|
|
plaintext: true,
|
|
path: "/tmp/secrets.json",
|
|
},
|
|
},
|
|
secretStorageResolver: async () => ({
|
|
kind: "file",
|
|
reason: "fallback",
|
|
durable: true,
|
|
plaintext,
|
|
path: "/tmp/secrets.json",
|
|
}),
|
|
});
|
|
|
|
const first = (await (
|
|
await app.request(new Request("http://test/api/config"))
|
|
).json()) as { secretStorage?: { plaintext?: boolean } };
|
|
expect(first.secretStorage?.plaintext).toBe(true);
|
|
|
|
plaintext = false; // the upgrading write happens
|
|
const second = (await (
|
|
await app.request(new Request("http://test/api/config"))
|
|
).json()) as { secretStorage?: { plaintext?: boolean } };
|
|
expect(second.secretStorage?.plaintext).toBe(false);
|
|
});
|
|
|
|
it("clears the field when the resolver says it is not known (round 9)", async () => {
|
|
// The resolver's contract is that `undefined` means "not known right
|
|
// now". Spreading it in conditionally left the *startup* descriptor
|
|
// standing in its place, so a store that became undescribable kept
|
|
// serving a stale, confident answer — the one thing this surface must
|
|
// never do.
|
|
const { app } = createRemoteApp({
|
|
dangerouslyOmitAuth: true,
|
|
initialConfig: {
|
|
defaultEnvironment: {},
|
|
secretStorage: {
|
|
kind: "file",
|
|
reason: "fallback",
|
|
durable: true,
|
|
plaintext: true,
|
|
path: "/tmp/secrets.json",
|
|
},
|
|
},
|
|
secretStorageResolver: async () => undefined,
|
|
});
|
|
|
|
const body = (await (
|
|
await app.request(new Request("http://test/api/config"))
|
|
).json()) as { secretStorage?: unknown };
|
|
expect(body.secretStorage).toBeUndefined();
|
|
});
|
|
|
|
it("falls back to the startup payload when no resolver is supplied", async () => {
|
|
// Embedders and the test suite pass no resolver; they must keep the
|
|
// previous behaviour rather than losing the field.
|
|
const { app } = createRemoteApp({
|
|
dangerouslyOmitAuth: true,
|
|
initialConfig: {
|
|
defaultEnvironment: {},
|
|
secretStorage: { kind: "keyring", reason: "default", durable: true },
|
|
},
|
|
});
|
|
const body = (await (
|
|
await app.request(new Request("http://test/api/config"))
|
|
).json()) as { secretStorage?: { kind?: string } };
|
|
expect(body.secretStorage?.kind).toBe("keyring");
|
|
});
|
|
});
|