mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-01 00:13:33 +00:00
fix: enforce Gemini CLI profile auth precedence
This commit is contained in:
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user