fix: stop leaking metadata values into diagnostics, and salvage per key

Two credential leaks, the same class already fixed for the client logger:
`serverList` printed the malformed entry verbatim — a legacy row missing `key`
still holds whatever the user configured — and the CLI overflow error echoed
the whole `key=value` pair into stderr and CI logs. Both now name the key and
the offending *type*; a test plants a `sk-live-…` and asserts it appears
nowhere while the key still does.

`normalizeStoredMetadata` now also drops a value that parses but cannot be
sent. A hand-edited catalog is the reachable source: it is parsed server-side,
so `1e400` really does become `Infinity` there, and the next write would store
`null`. Filtered per key, matching the per-item salvage this file already does
elsewhere, so one bad value does not cost the user the rest.

Deliberately *not* added to the `/api/servers` guard, which review also
suggested. Writing that test is what showed why: a client's own
`JSON.stringify` turns `Infinity` into `null` before the request is sent, so
the wire cannot carry the bad case at all — and the check made things worse,
rejecting the whole metadata field where the normalizer drops one key. The
guard stays a container check, with a comment recording both points.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177mPHAdECD18nLLCTwR5rh
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-23 20:03:37 -04:00
commit 56d8644032

View file

@ -1477,6 +1477,44 @@ describe("normalizeStoredMetadata (#1910)", () => {
expect(warn).toHaveBeenCalledTimes(1);
});
it("drops a value that parses but cannot be sent, keeping its siblings", () => {
// A hand-edited catalog reaches this untouched by any editor, so it is a
// real source of `1e400` — which parses to Infinity and serializes to null.
const out = normalizeStoredMetadata(
JSON.parse('{"good":1,"bad":1e400,"alsoGood":{"n":2}}'),
);
expect(out).toEqual({ good: 1, alsoGood: { n: 2 } });
expect(warn).toHaveBeenCalledTimes(1);
});
it("drops a non-serializable value nested inside an object", () => {
const out = normalizeStoredMetadata(JSON.parse('{"a":{"deep":[1e400]}}'));
expect(out).toEqual({});
});
it("applies the same check to a legacy pair value", () => {
const out = normalizeStoredMetadata([
{ key: "ok", value: 1 },
{ key: "bad", value: JSON.parse("1e400") },
]);
expect(out).toEqual({ ok: 1 });
});
it("never prints the offending value in a warning", () => {
// `_meta` can hold credentials, and these warnings go to ordinary console
// output — so a diagnostic may name the key and the type, never the value.
normalizeStoredMetadata([
{ value: { accessToken: "sk-live-nope" } },
{ key: "leaky", value: JSON.parse('{"token":1e400}') },
]);
normalizeStoredMetadata("sk-live-also-nope");
const printed = JSON.stringify(warn.mock.calls);
expect(printed).not.toContain("sk-live");
expect(printed).not.toContain("accessToken");
// The key is still named, so the warning stays actionable.
expect(printed).toContain("leaky");
});
it("migrates a legacy file to the object shape on the next write", () => {
// Read side accepts the pair array; write side emits only the object, so
// one round-trip is the migration.