fix: enforce Gemini CLI profile auth precedence

This commit is contained in:
Shakker
2026-06-15 20:17:23 +01:00
committed by Shakker
parent cef3293d31
commit ce007fbb1e
4 changed files with 172 additions and 8 deletions

View File

@@ -78,8 +78,11 @@ type GeminiApiKeyCredential = GeminiAuthProfileCredential & {
type GeminiCliAuthHomeContext = {
agentDir?: string;
authProfileId?: string;
systemSettingsPath?: string;
};
type GeminiCliAuthSelectedType = "oauth-personal" | "gemini-api-key";
function throwUnsupportedGeminiCredential(credential: GeminiAuthProfileCredential): never {
if (credential.provider === VERCEL_AI_GATEWAY_PROVIDER_ID) {
throw new Error(
@@ -195,6 +198,55 @@ function resolveGeminiCliProfileHome(ctx: GeminiCliAuthHomeContext): {
return { home, geminiDir: path.join(home, ".gemini") };
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
async function readGeminiCliJsonObject(
filePath: string | undefined,
): Promise<Record<string, unknown>> {
const normalized = normalizeString(filePath);
if (!normalized) {
return {};
}
try {
const parsed = JSON.parse(await fs.readFile(normalized, "utf8")) as unknown;
if (!isRecord(parsed)) {
throw new Error(`Gemini CLI system settings must be a JSON object: ${normalized}`);
}
return { ...parsed };
} catch (error) {
if (
error &&
typeof error === "object" &&
"code" in error &&
(error as { code?: unknown }).code === "ENOENT"
) {
return {};
}
throw error;
}
}
function buildGeminiCliAuthSettings(
selectedType: GeminiCliAuthSelectedType,
): Record<string, unknown> {
return { security: { auth: { selectedType } } };
}
async function buildGeminiCliSystemSettings(
ctx: GeminiCliAuthHomeContext,
selectedType: GeminiCliAuthSelectedType,
): Promise<Record<string, unknown>> {
const base = await readGeminiCliJsonObject(ctx.systemSettingsPath);
const security = isRecord(base.security) ? { ...base.security } : {};
security.auth = { selectedType };
return {
...base,
security,
};
}
async function writeGeminiCliJson(filePath: string, value: unknown): Promise<void> {
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
encoding: "utf8",
@@ -205,20 +257,25 @@ async function writeGeminiCliJson(filePath: string, value: unknown): Promise<voi
async function prepareGeminiCliProfileHome(
ctx: GeminiCliAuthHomeContext,
settings: unknown,
selectedType: GeminiCliAuthSelectedType,
): Promise<{
home: string;
geminiDir: string;
systemSettingsPath: string;
}> {
const { home, geminiDir } = resolveGeminiCliProfileHome(ctx);
await fs.mkdir(geminiDir, { recursive: true, mode: 0o700 });
await fs.chmod(home, 0o700);
await fs.chmod(geminiDir, 0o700);
const settings = buildGeminiCliAuthSettings(selectedType);
const systemSettings = await buildGeminiCliSystemSettings(ctx, selectedType);
const systemSettingsPath = path.join(home, "system-settings.json");
await Promise.all([
writeGeminiCliJson(path.join(geminiDir, "settings.json"), settings),
writeGeminiCliJson(path.join(home, "settings.json"), settings),
writeGeminiCliJson(systemSettingsPath, systemSettings),
]);
return { home, geminiDir };
return { home, geminiDir, systemSettingsPath };
}
async function clearGeminiCliCachedCredentials(geminiDir: string): Promise<void> {
@@ -248,9 +305,10 @@ async function prepareGeminiCliOAuthHome(
return null;
}
const { home, geminiDir } = await prepareGeminiCliProfileHome(ctx, {
security: { auth: { selectedType: "oauth-personal" } },
});
const { home, geminiDir, systemSettingsPath } = await prepareGeminiCliProfileHome(
ctx,
"oauth-personal",
);
await clearGeminiCliCachedCredentials(geminiDir);
const idToken = normalizeString(oauth.idToken);
const oauthCreds: Record<string, string | number> = {
@@ -268,6 +326,7 @@ async function prepareGeminiCliOAuthHome(
return {
env: {
GEMINI_CLI_HOME: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath,
GEMINI_FORCE_FILE_STORAGE: "true",
...buildGeminiCliProjectEnv(oauth.projectId),
},
@@ -284,9 +343,10 @@ async function prepareGeminiCliApiKeyHome(
return null;
}
const { home, geminiDir } = await prepareGeminiCliProfileHome(ctx, {
security: { auth: { selectedType: "gemini-api-key" } },
});
const { home, geminiDir, systemSettingsPath } = await prepareGeminiCliProfileHome(
ctx,
"gemini-api-key",
);
await Promise.all([
fs.rm(path.join(geminiDir, "oauth_creds.json"), { force: true }),
clearGeminiCliCachedCredentials(geminiDir),
@@ -294,6 +354,7 @@ async function prepareGeminiCliApiKeyHome(
return {
env: {
GEMINI_CLI_HOME: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath,
GEMINI_FORCE_FILE_STORAGE: "true",
GEMINI_API_KEY: apiKey.key,
},
@@ -339,6 +400,8 @@ export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
{
agentDir: ctx.agentDir,
authProfileId: ctx.authProfileId,
systemSettingsPath: (ctx as typeof ctx & { env?: Record<string, string> }).env
?.GEMINI_CLI_SYSTEM_SETTINGS_PATH,
},
(ctx as typeof ctx & { authCredential?: GeminiAuthProfileCredential }).authCredential,
),

View File

@@ -10,6 +10,7 @@ import setupEntry from "./setup-api.js";
type GeminiPrepareContext = Parameters<
NonNullable<ReturnType<typeof buildGoogleGeminiCliBackend>["prepareExecution"]>
>[0] & {
env?: Record<string, string>;
authCredential?: {
type: "api_key" | "oauth" | "token";
provider: string;
@@ -98,10 +99,24 @@ describe("google gemini cli backend auth bridge", () => {
try {
const context = buildGeminiOAuthPrepareContext(workspaceDir);
const inheritedSettingsPath = path.join(workspaceDir, "generated-mcp-settings.json");
await fs.writeFile(
inheritedSettingsPath,
`${JSON.stringify({
security: { auth: { selectedType: "vertex-ai" } },
mcp: { allowed: ["openclaw"] },
mcpServers: { openclaw: { url: "http://127.0.0.1:23119/mcp" } },
})}\n`,
"utf8",
);
context.env = { GEMINI_CLI_SYSTEM_SETTINGS_PATH: inheritedSettingsPath };
const prepared = await backend.prepareExecution?.(context);
home = prepared?.env?.GEMINI_CLI_HOME;
const systemSettingsPath = prepared?.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
expect(home).toBeTruthy();
expect(systemSettingsPath).toBeTruthy();
expect(systemSettingsPath).not.toBe(inheritedSettingsPath);
expect(prepared?.env?.GEMINI_FORCE_FILE_STORAGE).toBe("true");
expect(prepared?.env?.GOOGLE_CLOUD_PROJECT).toBe("profile-project");
expect(prepared?.env?.GOOGLE_CLOUD_PROJECT_ID).toBe("profile-project");
@@ -126,6 +141,12 @@ describe("google gemini cli backend auth bridge", () => {
security: { auth: { selectedType: "oauth-personal" } },
});
expect(JSON.parse(rootSettingsRaw)).toEqual(JSON.parse(nestedSettingsRaw));
const systemSettingsRaw = await fs.readFile(systemSettingsPath ?? "", "utf8");
expect(JSON.parse(systemSettingsRaw)).toEqual({
security: { auth: { selectedType: "oauth-personal" } },
mcp: { allowed: ["openclaw"] },
mcpServers: { openclaw: { url: "http://127.0.0.1:23119/mcp" } },
});
const sessionMarker = path.join(home ?? "", ".gemini", "session-state.json");
await fs.writeFile(sessionMarker, '{"keep":true}\n', "utf8");

View File

@@ -670,6 +670,85 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
}
});
it("lets Gemini CLI preparation override generated MCP system settings auth", async () => {
const { dir, sessionFile } = createSessionFile();
const profileSystemSettingsPath = path.join(dir, "profile-system-settings.json");
const getActiveMcpLoopbackRuntime = vi.fn(() => ({
port: 31783,
ownerToken: "loopback-owner-token",
nonOwnerToken: "loopback-non-owner-token",
}));
const prepareExecution = vi.fn(async () => ({
env: {
GEMINI_CLI_SYSTEM_SETTINGS_PATH: profileSystemSettingsPath,
},
}));
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "google-gemini-cli",
pluginId: "google",
bundleMcp: true,
bundleMcpMode: "gemini-system-settings",
prepareExecution,
config: {
command: "gemini",
args: ["--prompt", "{prompt}"],
output: "json",
input: "arg",
sessionMode: "existing",
},
},
],
});
setCliRunnerPrepareTestDeps({
getActiveMcpLoopbackRuntime,
ensureMcpLoopbackServer: vi.fn(createTestMcpLoopbackServer),
createMcpLoopbackServerConfig: vi.fn(createTestMcpLoopbackServerConfig),
resolveMcpLoopbackBearerToken: vi.fn(() => "loopback-token"),
resolveMcpLoopbackScopedTools: vi.fn(() => ({ agentId: "main", tools: [] })),
});
let cleanup: (() => Promise<void>) | undefined;
try {
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionKey: "agent:main:main",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "google-gemini-cli",
model: "gemini-3.1-pro-preview",
timeoutMs: 1_000,
runId: "run-test-gemini-mcp-system-settings",
config: {},
});
cleanup = context.preparedBackend.cleanup;
const prepareExecutionArg = prepareExecution.mock.calls[0]?.[0] as
| { env?: Record<string, string> }
| undefined;
const generatedSystemSettingsPath = prepareExecutionArg?.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
expect(typeof generatedSystemSettingsPath).toBe("string");
expect(generatedSystemSettingsPath).not.toBe(profileSystemSettingsPath);
const generatedSettings = JSON.parse(
fs.readFileSync(generatedSystemSettingsPath ?? "", "utf8"),
) as {
mcp?: { allowed?: string[] };
mcpServers?: Record<string, { url?: string }>;
};
expect(generatedSettings.mcp?.allowed).toEqual(["openclaw"]);
expect(generatedSettings.mcpServers?.openclaw?.url).toBe("http://127.0.0.1:31783/mcp");
expect(context.preparedBackend.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH).toBe(
profileSystemSettingsPath,
);
} finally {
await cleanup?.();
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("prepares side questions without agent-turn context, tools, hooks, or reusable sessions", async () => {
const { dir, sessionFile } = createSessionFile();
appendTranscriptEntry(sessionFile, {

View File

@@ -474,6 +474,7 @@ export async function prepareCliRunContext(
modelId,
authProfileId: effectiveAuthProfileId,
executionMode,
env: preparedBackend.env,
} as Parameters<NonNullable<typeof backendResolved.prepareExecution>>[0];
const preparedExecution = await backendResolved.prepareExecution?.(
(backendResolved.id === "google-gemini-cli"