fix(gemini): compare consolidated allowlists through one normalizer (#2495)

* fix(gemini): compare consolidated allowlists through one normalizer

geminiServerPolicyBlock consolidates allowlists by normalized intersection but
tested the consolidated list against the raw server key. Because the
intersection keeps the spelling of whichever policy came first, the same pair of
policies blocked the server in one arrangement and allowed it in the other, and
the excluded test had the mirror-image asymmetry.

Normalize the comparison key once and use the same normalizer for the exclusion
test, the intersection and the membership test. The messages keep the
caller-facing server key, so lookup and display stay separate.

Verified with 88 client profile, lifecycle and registration tests, check:fast
and verify:test-discovery on macOS arm64. The regression test drives setup twice
with the same policies in both arrangements; before the change one arrangement
failed and the other passed.

* test(gemini): cover normalized exclusions and padded allowlist entries

The regression case added with the fix drove the allowlist intersection in
both spellings but left the other two comparisons the change touches
uncovered: the exclusion membership test and the trimming applied to padded
names. Reverting either behaviour kept the suite green, so neither was
pinned. Greptile raised exactly this on the PR.

An exclusion is now exercised as " REA " against the selected server, and a
padded allowlist entry is exercised as the only policy, so a padded entry has
to permit the same server its exact spelling permits rather than fall to the
membership test.

Verified with `npm run test:focused -- tests/boundary/cli/clientProfiles.test.ts`:
14 passed with the change; reverting only ClientConfigurationDocument.ts to
main fails both cases, the arrangement case with `rea+REA: expected 1 to be +0`
and the new one on both the excluded and the padded-allowlist assertions.

* fix(process): report no remaining time after a provider deadline fires

Node timers can fire while performance.now() is still a fraction of a
millisecond short of the monotonic deadline. ProviderStartupDeadline then
reported interruption "timeout" and an aborted signal while remainingMs()
returned ceil(0.x) = 1, so polling loops guarded by remainingMs() > 0
(Hopper and Ghidra startup) could run past an elapsed deadline, and the
real-timer test "aborts a bounded wait when its real monotonic deadline
expires" failed intermittently in CI (expected 1 to be 0).

Once the deadline's own timer has classified a timeout, report zero
remaining time. A deterministic regression test fires the timer with the
monotonic clock 0.4 ms short of the deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: 吴佳浩 <wujiahao@supremind.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
吴佳浩Alben 2026-10-14 06:43:03 +08:00 committed by GitHub
parent 1f122c02b7
commit b9b3a9098b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 102 additions and 6 deletions

View File

@ -1520,12 +1520,25 @@ export const parseGeminiMcpPolicy = (parsed: ClientConfigurationDocument) =>
.passthrough()
.parse(parsed.document.mcp ?? {});
/** One server-name comparison for the exclusion, intersection and membership rules below. */
const normalizeGeminiServerName = (name: string): string =>
name.toLowerCase().trim();
/** Gemini consolidates excludes by union and allowlists by normalized intersection. */
export const geminiServerPolicyBlock = (
policies: readonly ReturnType<typeof parseGeminiMcpPolicy>[],
serverKey: string,
): string | undefined => {
if (policies.some(({ excluded }) => excluded?.includes(serverKey)))
// Hand-authored settings cannot be assumed to spell the product key exactly,
// so every rule compares normalized names. Testing the consolidated list
// against the raw key instead would make the outcome depend on which policy
// supplied the spelling that survived the intersection.
const key = normalizeGeminiServerName(serverKey);
if (
policies.some(({ excluded }) =>
excluded?.some((name) => normalizeGeminiServerName(name) === key),
)
)
return `Gemini mcp.excluded blocks ${serverKey}. Review the selected settings policies before rerunning setup.`;
const allowlists = policies.flatMap(({ allowed }) =>
allowed === undefined ? [] : [allowed],
@ -1533,11 +1546,9 @@ export const geminiServerPolicyBlock = (
const allowed = allowlists.reduce<string[] | undefined>(
(previous, current) => {
if (previous === undefined) return current;
const normalized = new Set(
current.map((name) => name.toLowerCase().trim()),
);
const normalized = new Set(current.map(normalizeGeminiServerName));
return previous.filter((name) =>
normalized.has(name.toLowerCase().trim()),
normalized.has(normalizeGeminiServerName(name)),
);
},
undefined,
@ -1546,7 +1557,7 @@ export const geminiServerPolicyBlock = (
if (
allowed !== undefined &&
allowed.length > 0 &&
!allowed.includes(serverKey)
!allowed.some((name) => normalizeGeminiServerName(name) === key)
)
return `Gemini mcp.allowed does not include ${serverKey}. Review the selected settings policies before rerunning setup.`;
return undefined;

View File

@ -262,6 +262,91 @@ cliTest(
},
);
cliTest(
"Gemini matches allowlist spellings independently of their policy order",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-gemini-case-");
const system = join(home, "system.json");
const defaults = join(home, "defaults.json");
await writeFile(system, JSON.stringify({ mcp: { allowed: ["REA"] } }));
const environment = {
HOME: home,
USERPROFILE: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: system,
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: defaults,
};
const run = () =>
cli.run({
arguments: ["setup", "--client", "gemini_cli", "--yes", "--json"],
cwd: home,
environment,
});
// The intersection compares names case-insensitively, so the spelling that
// survives must not decide the outcome: the same pair of policies in
// either arrangement has to consolidate to the same decision.
for (const [systemSpelling, defaultSpelling] of [
["REA", "rea"],
["rea", "REA"],
]) {
await writeFile(
system,
JSON.stringify({ mcp: { allowed: [systemSpelling] } }),
);
await writeFile(
defaults,
JSON.stringify({ mcp: { allowed: [defaultSpelling] } }),
);
const result = await run();
expect(result.exitCode, `${systemSpelling}+${defaultSpelling}`).toBe(0);
expect(result.json, `${systemSpelling}+${defaultSpelling}`).toMatchObject(
{
status: "ready",
},
);
}
},
);
cliTest(
"Gemini compares exclusions and padded allowlist entries through one normalization",
async ({ cli }) => {
const home = await createTestTempDirectory("rea-gemini-normalized-");
const system = join(home, "system.json");
const defaults = join(home, "defaults.json");
const environment = {
HOME: home,
USERPROFILE: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: system,
GEMINI_CLI_SYSTEM_DEFAULTS_PATH: defaults,
};
const run = () =>
cli.run({
arguments: ["setup", "--client", "gemini_cli", "--yes", "--json"],
cwd: home,
environment,
});
await writeFile(defaults, JSON.stringify({ mcp: {} }));
// An exclusion is a membership test over the same names the intersection
// compares, so a differently spelled and padded entry blocks setup rather
// than slipping past the raw comparison.
await writeFile(system, JSON.stringify({ mcp: { excluded: [" REA "] } }));
const blocked = await run();
expect(blocked.exitCode).toBe(1);
expect(blocked.json).toMatchObject({
status: "needs_human",
plannedActions: [],
appliedActions: [],
remediation: expect.stringContaining("mcp.excluded"),
});
// Padding is presentation, not identity: a padded allowlist entry permits
// the same server its exact spelling permits.
await writeFile(system, JSON.stringify({ mcp: { allowed: [" REA "] } }));
const allowed = await run();
expect(allowed.exitCode).toBe(0);
expect(allowed.json).toMatchObject({ status: "ready" });
},
);
cliTest(
"OpenCode doctor sees a global registration when the custom directory is absent",
async ({ cli }) => {