mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 19:12:22 +00:00
fix: persist Gemini CLI auth homes
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { CliBackendPlugin } from "openclaw/plugin-sdk/cli-backend";
|
||||
@@ -5,7 +6,6 @@ import {
|
||||
CLI_FRESH_WATCHDOG_DEFAULTS,
|
||||
CLI_RESUME_WATCHDOG_DEFAULTS,
|
||||
} from "openclaw/plugin-sdk/cli-backend";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
|
||||
const GEMINI_MODEL_ALIASES: Record<string, string> = {
|
||||
pro: "gemini-3.1-pro-preview",
|
||||
@@ -15,10 +15,12 @@ const GEMINI_MODEL_ALIASES: Record<string, string> = {
|
||||
const GEMINI_CLI_DEFAULT_MODEL_REF = "google-gemini-cli/gemini-3-flash-preview";
|
||||
const GEMINI_CLI_PROVIDER_ID = "google-gemini-cli";
|
||||
const VERCEL_AI_GATEWAY_PROVIDER_ID = "vercel-ai-gateway";
|
||||
const GEMINI_CLI_CREDENTIALS_FILENAME = "gemini-credentials.json";
|
||||
const GEMINI_CLI_GCA_AUTH_ENV = [
|
||||
"GOOGLE_GENAI_USE_GCA",
|
||||
"GOOGLE_CLOUD_ACCESS_TOKEN",
|
||||
"GEMINI_FORCE_ENCRYPTED_FILE_STORAGE",
|
||||
"GEMINI_FORCE_FILE_STORAGE",
|
||||
];
|
||||
const GEMINI_CLI_API_KEY_AUTH_ENV = [
|
||||
...GEMINI_CLI_GCA_AUTH_ENV,
|
||||
@@ -31,11 +33,11 @@ const GEMINI_CLI_API_KEY_AUTH_ENV = [
|
||||
"GEMINI_CLI_CUSTOM_HEADERS",
|
||||
"GEMINI_API_KEY_AUTH_MECHANISM",
|
||||
];
|
||||
const GEMINI_CLI_PROFILE_AUTH_ENV = [...GEMINI_CLI_API_KEY_AUTH_ENV, "GEMINI_API_KEY"];
|
||||
|
||||
type PreparedGeminiCliExecution = {
|
||||
env: Record<string, string>;
|
||||
clearEnv: string[];
|
||||
cleanup: () => Promise<void>;
|
||||
};
|
||||
|
||||
function normalizeString(value: string | undefined): string | undefined {
|
||||
@@ -68,6 +70,11 @@ type GeminiApiKeyCredential = GeminiAuthProfileCredential & {
|
||||
key: string;
|
||||
};
|
||||
|
||||
type GeminiCliAuthHomeContext = {
|
||||
agentDir?: string;
|
||||
authProfileId?: string;
|
||||
};
|
||||
|
||||
function throwUnsupportedGeminiCredential(credential: GeminiAuthProfileCredential): never {
|
||||
if (credential.provider === VERCEL_AI_GATEWAY_PROVIDER_ID) {
|
||||
throw new Error(
|
||||
@@ -140,25 +147,58 @@ function requireGeminiApiKeyCredential(
|
||||
};
|
||||
}
|
||||
|
||||
async function createIsolatedGeminiCliHome(settings: unknown): Promise<{
|
||||
tempHome: string;
|
||||
function resolveGeminiCliProfileHome(ctx: GeminiCliAuthHomeContext): {
|
||||
home: string;
|
||||
geminiDir: string;
|
||||
} {
|
||||
const agentDir = normalizeString(ctx.agentDir);
|
||||
if (!agentDir) {
|
||||
throw new Error("Gemini CLI auth profile execution requires an agent directory.");
|
||||
}
|
||||
const authProfileId = normalizeString(ctx.authProfileId);
|
||||
if (!authProfileId) {
|
||||
throw new Error("Gemini CLI auth profile execution requires a selected auth profile.");
|
||||
}
|
||||
|
||||
const profileHash = crypto.createHash("sha256").update(authProfileId).digest("hex").slice(0, 24);
|
||||
const home = path.join(agentDir, "cli-runtimes", GEMINI_CLI_PROVIDER_ID, "profiles", profileHash);
|
||||
return { home, geminiDir: path.join(home, ".gemini") };
|
||||
}
|
||||
|
||||
async function writeGeminiCliJson(filePath: string, value: unknown): Promise<void> {
|
||||
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
await fs.chmod(filePath, 0o600);
|
||||
}
|
||||
|
||||
async function prepareGeminiCliProfileHome(
|
||||
ctx: GeminiCliAuthHomeContext,
|
||||
settings: unknown,
|
||||
): Promise<{
|
||||
home: string;
|
||||
geminiDir: string;
|
||||
}> {
|
||||
const tempHome = await fs.mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "gemini-cli-home-"),
|
||||
);
|
||||
await fs.chmod(tempHome, 0o700);
|
||||
const geminiDir = path.join(tempHome, ".gemini");
|
||||
const { home, geminiDir } = resolveGeminiCliProfileHome(ctx);
|
||||
await fs.mkdir(geminiDir, { recursive: true, mode: 0o700 });
|
||||
await fs.writeFile(
|
||||
path.join(geminiDir, "settings.json"),
|
||||
`${JSON.stringify(settings, null, 2)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
return { tempHome, geminiDir };
|
||||
await fs.chmod(home, 0o700);
|
||||
await fs.chmod(geminiDir, 0o700);
|
||||
await Promise.all([
|
||||
writeGeminiCliJson(path.join(geminiDir, "settings.json"), settings),
|
||||
writeGeminiCliJson(path.join(home, "settings.json"), settings),
|
||||
]);
|
||||
return { home, geminiDir };
|
||||
}
|
||||
|
||||
async function clearGeminiCliCachedCredentials(geminiDir: string): Promise<void> {
|
||||
// Gemini prefers its token store over oauth_creds.json. Rebuild that store
|
||||
// from the selected OpenClaw profile each run so stale CLI auth cannot win.
|
||||
await fs.rm(path.join(geminiDir, GEMINI_CLI_CREDENTIALS_FILENAME), { force: true });
|
||||
}
|
||||
|
||||
async function prepareGeminiCliOAuthHome(
|
||||
ctx: GeminiCliAuthHomeContext,
|
||||
credential: GeminiAuthProfileCredential | undefined,
|
||||
): Promise<PreparedGeminiCliExecution | null> {
|
||||
const oauth = requireGeminiOAuthCredential(credential);
|
||||
@@ -166,43 +206,34 @@ async function prepareGeminiCliOAuthHome(
|
||||
return null;
|
||||
}
|
||||
|
||||
const { tempHome, geminiDir } = await createIsolatedGeminiCliHome({
|
||||
const { home, geminiDir } = await prepareGeminiCliProfileHome(ctx, {
|
||||
security: { auth: { selectedType: "oauth-personal" } },
|
||||
});
|
||||
try {
|
||||
const idToken = normalizeString(oauth.idToken);
|
||||
const oauthCreds: Record<string, string | number> = {
|
||||
access_token: oauth.access,
|
||||
refresh_token: oauth.refresh,
|
||||
expiry_date: oauth.expires,
|
||||
token_type: "Bearer",
|
||||
};
|
||||
if (idToken) {
|
||||
oauthCreds.id_token = idToken;
|
||||
}
|
||||
|
||||
await fs.writeFile(
|
||||
path.join(geminiDir, "oauth_creds.json"),
|
||||
`${JSON.stringify(oauthCreds, null, 2)}\n`,
|
||||
{ encoding: "utf8", mode: 0o600 },
|
||||
);
|
||||
|
||||
return {
|
||||
env: {
|
||||
GEMINI_CLI_HOME: tempHome,
|
||||
},
|
||||
clearEnv: [...GEMINI_CLI_GCA_AUTH_ENV],
|
||||
cleanup: async () => {
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
throw error;
|
||||
await clearGeminiCliCachedCredentials(geminiDir);
|
||||
const idToken = normalizeString(oauth.idToken);
|
||||
const oauthCreds: Record<string, string | number> = {
|
||||
access_token: oauth.access,
|
||||
refresh_token: oauth.refresh,
|
||||
expiry_date: oauth.expires,
|
||||
token_type: "Bearer",
|
||||
};
|
||||
if (idToken) {
|
||||
oauthCreds.id_token = idToken;
|
||||
}
|
||||
|
||||
await writeGeminiCliJson(path.join(geminiDir, "oauth_creds.json"), oauthCreds);
|
||||
|
||||
return {
|
||||
env: {
|
||||
GEMINI_CLI_HOME: home,
|
||||
GEMINI_FORCE_FILE_STORAGE: "true",
|
||||
},
|
||||
clearEnv: [...GEMINI_CLI_PROFILE_AUTH_ENV],
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareGeminiCliApiKeyHome(
|
||||
ctx: GeminiCliAuthHomeContext,
|
||||
credential: GeminiAuthProfileCredential | undefined,
|
||||
): Promise<PreparedGeminiCliExecution | null> {
|
||||
const apiKey = requireGeminiApiKeyCredential(credential);
|
||||
@@ -210,31 +241,30 @@ async function prepareGeminiCliApiKeyHome(
|
||||
return null;
|
||||
}
|
||||
|
||||
const { tempHome } = await createIsolatedGeminiCliHome({
|
||||
const { home, geminiDir } = await prepareGeminiCliProfileHome(ctx, {
|
||||
security: { auth: { selectedType: "gemini-api-key" } },
|
||||
});
|
||||
try {
|
||||
return {
|
||||
env: {
|
||||
GEMINI_CLI_HOME: tempHome,
|
||||
GEMINI_API_KEY: apiKey.key,
|
||||
},
|
||||
clearEnv: [...GEMINI_CLI_API_KEY_AUTH_ENV],
|
||||
cleanup: async () => {
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await fs.rm(tempHome, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
await Promise.all([
|
||||
fs.rm(path.join(geminiDir, "oauth_creds.json"), { force: true }),
|
||||
clearGeminiCliCachedCredentials(geminiDir),
|
||||
]);
|
||||
return {
|
||||
env: {
|
||||
GEMINI_CLI_HOME: home,
|
||||
GEMINI_FORCE_FILE_STORAGE: "true",
|
||||
GEMINI_API_KEY: apiKey.key,
|
||||
},
|
||||
clearEnv: [...GEMINI_CLI_PROFILE_AUTH_ENV],
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareGeminiCliAuthHome(
|
||||
ctx: GeminiCliAuthHomeContext,
|
||||
credential: GeminiAuthProfileCredential | undefined,
|
||||
): Promise<PreparedGeminiCliExecution | null> {
|
||||
return (
|
||||
(await prepareGeminiCliOAuthHome(credential)) ?? (await prepareGeminiCliApiKeyHome(credential))
|
||||
(await prepareGeminiCliOAuthHome(ctx, credential)) ??
|
||||
(await prepareGeminiCliApiKeyHome(ctx, credential))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -257,6 +287,10 @@ export function buildGoogleGeminiCliBackend(): CliBackendPlugin {
|
||||
authEpochMode: "profile-only",
|
||||
prepareExecution: async (ctx) =>
|
||||
await prepareGeminiCliAuthHome(
|
||||
{
|
||||
agentDir: ctx.agentDir,
|
||||
authProfileId: ctx.authProfileId,
|
||||
},
|
||||
(ctx as typeof ctx & { authCredential?: GeminiAuthProfileCredential }).authCredential,
|
||||
),
|
||||
config: {
|
||||
|
||||
@@ -23,8 +23,10 @@ type GeminiPrepareContext = Parameters<
|
||||
};
|
||||
|
||||
function buildGeminiOAuthPrepareContext(workspaceDir: string): GeminiPrepareContext {
|
||||
const agentDir = path.join(workspaceDir, "agent");
|
||||
return {
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
provider: "google-gemini-cli",
|
||||
modelId: "gemini-3.1-pro-preview",
|
||||
authProfileId: "google-gemini-cli:user@example.test",
|
||||
@@ -42,8 +44,10 @@ function buildGeminiOAuthPrepareContext(workspaceDir: string): GeminiPrepareCont
|
||||
}
|
||||
|
||||
function buildGeminiApiKeyPrepareContext(workspaceDir: string): GeminiPrepareContext {
|
||||
const agentDir = path.join(workspaceDir, "agent");
|
||||
return {
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
provider: "google-gemini-cli",
|
||||
modelId: "gemini-3.1-flash-lite",
|
||||
authProfileId: "google-gemini-cli:api-key",
|
||||
@@ -85,20 +89,20 @@ describe("google setup entry", () => {
|
||||
});
|
||||
|
||||
describe("google gemini cli backend auth bridge", () => {
|
||||
it("materializes selected OpenClaw OAuth credentials into an isolated Gemini CLI home", async () => {
|
||||
it("materializes selected OpenClaw OAuth credentials into a persistent profile-scoped Gemini CLI home", async () => {
|
||||
const backend = buildGoogleGeminiCliBackend();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-workspace-"));
|
||||
let prepared:
|
||||
| Awaited<ReturnType<NonNullable<typeof backend.prepareExecution>>>
|
||||
| null
|
||||
| undefined;
|
||||
let home: string | undefined;
|
||||
|
||||
try {
|
||||
prepared = await backend.prepareExecution?.(buildGeminiOAuthPrepareContext(workspaceDir));
|
||||
const context = buildGeminiOAuthPrepareContext(workspaceDir);
|
||||
const prepared = await backend.prepareExecution?.(context);
|
||||
|
||||
home = prepared?.env?.GEMINI_CLI_HOME;
|
||||
expect(home).toBeTruthy();
|
||||
expect(prepared?.env?.GEMINI_FORCE_FILE_STORAGE).toBe("true");
|
||||
expect(home).toContain(path.join(context.agentDir, "cli-runtimes", "google-gemini-cli"));
|
||||
expect(home).not.toContain("user@example.test");
|
||||
|
||||
const raw = await fs.readFile(path.join(home ?? "", ".gemini", "oauth_creds.json"), "utf8");
|
||||
expect(JSON.parse(raw)).toEqual({
|
||||
@@ -108,29 +112,54 @@ describe("google gemini cli backend auth bridge", () => {
|
||||
expiry_date: 1_800_000_000_000,
|
||||
token_type: "Bearer",
|
||||
});
|
||||
const nestedSettingsRaw = await fs.readFile(
|
||||
path.join(home ?? "", ".gemini", "settings.json"),
|
||||
"utf8",
|
||||
);
|
||||
const rootSettingsRaw = await fs.readFile(path.join(home ?? "", "settings.json"), "utf8");
|
||||
expect(JSON.parse(nestedSettingsRaw)).toEqual({
|
||||
security: { auth: { selectedType: "oauth-personal" } },
|
||||
});
|
||||
expect(JSON.parse(rootSettingsRaw)).toEqual(JSON.parse(nestedSettingsRaw));
|
||||
|
||||
const sessionMarker = path.join(home ?? "", ".gemini", "session-state.json");
|
||||
await fs.writeFile(sessionMarker, '{"keep":true}\n', "utf8");
|
||||
const cachedCredentialsPath = path.join(home ?? "", ".gemini", "gemini-credentials.json");
|
||||
await fs.writeFile(cachedCredentialsPath, "stale-cache", "utf8");
|
||||
|
||||
const preparedAgain = await backend.prepareExecution?.(context);
|
||||
expect(preparedAgain?.env?.GEMINI_CLI_HOME).toBe(home);
|
||||
await expect(fs.access(sessionMarker)).resolves.toBeUndefined();
|
||||
await expect(fs.access(cachedCredentialsPath)).rejects.toThrow();
|
||||
} finally {
|
||||
await prepared?.cleanup?.();
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await expect(fs.access(home ?? "")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("prepares selected Gemini API-key credentials without writing OAuth state", async () => {
|
||||
it("prepares selected Gemini API-key credentials and removes stale OAuth state for that profile home", async () => {
|
||||
const backend = buildGoogleGeminiCliBackend();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-workspace-"));
|
||||
let prepared:
|
||||
| Awaited<ReturnType<NonNullable<typeof backend.prepareExecution>>>
|
||||
| null
|
||||
| undefined;
|
||||
let home: string | undefined;
|
||||
|
||||
try {
|
||||
prepared = await backend.prepareExecution?.(buildGeminiApiKeyPrepareContext(workspaceDir));
|
||||
const context = buildGeminiApiKeyPrepareContext(workspaceDir);
|
||||
const firstPrepared = await backend.prepareExecution?.(context);
|
||||
home = firstPrepared?.env?.GEMINI_CLI_HOME;
|
||||
expect(home).toBeTruthy();
|
||||
await fs.writeFile(path.join(home ?? "", ".gemini", "oauth_creds.json"), "{}\n", "utf8");
|
||||
await fs.writeFile(
|
||||
path.join(home ?? "", ".gemini", "gemini-credentials.json"),
|
||||
"stale-cache",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const prepared = await backend.prepareExecution?.(context);
|
||||
|
||||
home = prepared?.env?.GEMINI_CLI_HOME;
|
||||
expect(home).toBeTruthy();
|
||||
expect(prepared?.env?.GEMINI_API_KEY).toBe("gemini-api-key");
|
||||
expect(prepared?.env?.GEMINI_FORCE_FILE_STORAGE).toBe("true");
|
||||
expect(prepared?.clearEnv).toContain("GEMINI_API_KEY");
|
||||
expect(prepared?.clearEnv).toContain("GOOGLE_GENAI_USE_GCA");
|
||||
expect(prepared?.clearEnv).toContain("GOOGLE_GENAI_USE_VERTEXAI");
|
||||
expect(prepared?.clearEnv).toContain("GOOGLE_GEMINI_BASE_URL");
|
||||
@@ -145,12 +174,12 @@ describe("google gemini cli backend auth bridge", () => {
|
||||
await expect(
|
||||
fs.access(path.join(home ?? "", ".gemini", "oauth_creds.json")),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
fs.access(path.join(home ?? "", ".gemini", "gemini-credentials.json")),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await prepared?.cleanup?.();
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
await expect(fs.access(home ?? "")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects Vercel AI Gateway profiles for the Gemini CLI backend", async () => {
|
||||
@@ -161,6 +190,7 @@ describe("google gemini cli backend auth bridge", () => {
|
||||
await expect(
|
||||
backend.prepareExecution?.({
|
||||
workspaceDir,
|
||||
agentDir: path.join(workspaceDir, "agent"),
|
||||
provider: "google-gemini-cli",
|
||||
modelId: "gemini-3.1-flash-lite",
|
||||
authProfileId: "vercel-ai-gateway:default",
|
||||
@@ -176,12 +206,14 @@ describe("google gemini cli backend auth bridge", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("clears inherited Gemini GCA credentials when staging selected OAuth credentials", async () => {
|
||||
it("clears inherited Gemini auth credentials when staging selected OAuth credentials", async () => {
|
||||
const backend = buildGoogleGeminiCliBackend();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-workspace-"));
|
||||
const originalUseGca = process.env.GOOGLE_GENAI_USE_GCA;
|
||||
const originalCloudAccessToken = process.env.GOOGLE_CLOUD_ACCESS_TOKEN;
|
||||
const originalForceEncryptedFileStorage = process.env.GEMINI_FORCE_ENCRYPTED_FILE_STORAGE;
|
||||
const originalGeminiApiKey = process.env.GEMINI_API_KEY;
|
||||
const originalGoogleApiKey = process.env.GOOGLE_API_KEY;
|
||||
let prepared:
|
||||
| Awaited<ReturnType<NonNullable<typeof backend.prepareExecution>>>
|
||||
| null
|
||||
@@ -190,6 +222,8 @@ describe("google gemini cli backend auth bridge", () => {
|
||||
process.env.GOOGLE_GENAI_USE_GCA = "true";
|
||||
process.env.GOOGLE_CLOUD_ACCESS_TOKEN = "ambient-cloud-token";
|
||||
process.env.GEMINI_FORCE_ENCRYPTED_FILE_STORAGE = "true";
|
||||
process.env.GEMINI_API_KEY = "ambient-gemini-key";
|
||||
process.env.GOOGLE_API_KEY = "ambient-google-key";
|
||||
|
||||
try {
|
||||
prepared = await backend.prepareExecution?.(buildGeminiOAuthPrepareContext(workspaceDir));
|
||||
@@ -199,16 +233,40 @@ describe("google gemini cli backend auth bridge", () => {
|
||||
"GOOGLE_GENAI_USE_GCA",
|
||||
"GOOGLE_CLOUD_ACCESS_TOKEN",
|
||||
"GEMINI_FORCE_ENCRYPTED_FILE_STORAGE",
|
||||
"GEMINI_FORCE_FILE_STORAGE",
|
||||
"GOOGLE_GENAI_USE_VERTEXAI",
|
||||
"GOOGLE_API_KEY",
|
||||
"GOOGLE_CLOUD_PROJECT",
|
||||
"GOOGLE_CLOUD_PROJECT_ID",
|
||||
"GOOGLE_CLOUD_LOCATION",
|
||||
"GOOGLE_GEMINI_BASE_URL",
|
||||
"GEMINI_CLI_CUSTOM_HEADERS",
|
||||
"GEMINI_API_KEY_AUTH_MECHANISM",
|
||||
"GEMINI_API_KEY",
|
||||
]);
|
||||
} finally {
|
||||
restoreEnv("GOOGLE_GENAI_USE_GCA", originalUseGca);
|
||||
restoreEnv("GOOGLE_CLOUD_ACCESS_TOKEN", originalCloudAccessToken);
|
||||
restoreEnv("GEMINI_FORCE_ENCRYPTED_FILE_STORAGE", originalForceEncryptedFileStorage);
|
||||
restoreEnv("GEMINI_API_KEY", originalGeminiApiKey);
|
||||
restoreEnv("GOOGLE_API_KEY", originalGoogleApiKey);
|
||||
await prepared?.cleanup?.();
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("requires an agent directory for profile-owned Gemini CLI state", async () => {
|
||||
const backend = buildGoogleGeminiCliBackend();
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-workspace-"));
|
||||
|
||||
try {
|
||||
const { agentDir: _agentDir, ...context } = buildGeminiOAuthPrepareContext(workspaceDir);
|
||||
await expect(backend.prepareExecution?.(context)).rejects.toThrow(/agent directory/);
|
||||
} finally {
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses profile-only auth epochs for the private Gemini CLI bridge", () => {
|
||||
const backend = buildGoogleGeminiCliBackend();
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
createTestRegistry,
|
||||
} from "../../test-utils/channel-plugins.js";
|
||||
import { captureEnv, setTestEnvValue } from "../../test-utils/env.js";
|
||||
import { resolveApiKeyForProfile as resolveApiKeyForProfileImpl } from "../auth-profiles/oauth.js";
|
||||
import { saveAuthProfileStore } from "../auth-profiles/store.js";
|
||||
import { testing as cliBackendsTesting } from "../cli-backends.js";
|
||||
import { hashCliSessionText } from "../cli-session.js";
|
||||
import { resetContextWindowCacheForTest } from "../context.js";
|
||||
@@ -263,6 +265,7 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
args: [],
|
||||
cleanup: vi.fn(async () => undefined),
|
||||
})),
|
||||
resolveApiKeyForProfile: resolveApiKeyForProfileImpl,
|
||||
});
|
||||
mockGetGlobalHookRunner.mockReturnValue(null);
|
||||
getRuntimeConfigMock.mockReturnValue({});
|
||||
@@ -321,6 +324,92 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("passes raw refreshed OAuth profile fields to profile-owned CLI preparation", async () => {
|
||||
const { dir, sessionFile } = createSessionFile();
|
||||
const agentDir = path.join(dir, "agents", "main", "agent");
|
||||
const authProfileId = "google-gemini-cli:user@example.test";
|
||||
const prepareExecution = vi.fn(async () => ({
|
||||
env: { GEMINI_CLI_HOME: path.join(agentDir, "gemini-home") },
|
||||
}));
|
||||
const resolveApiKeyForProfile = vi.fn(async () => ({
|
||||
apiKey: JSON.stringify({ token: "provider-formatted-access", projectId: "project-1" }),
|
||||
provider: "google-gemini-cli",
|
||||
email: "user@example.test",
|
||||
}));
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
saveAuthProfileStore(
|
||||
{
|
||||
version: 1,
|
||||
profiles: {
|
||||
[authProfileId]: {
|
||||
type: "oauth",
|
||||
provider: "google-gemini-cli",
|
||||
access: "raw-access-token",
|
||||
refresh: "raw-refresh-token",
|
||||
expires: 1_800_000_000_000,
|
||||
projectId: "project-1",
|
||||
email: "user@example.test",
|
||||
},
|
||||
},
|
||||
},
|
||||
agentDir,
|
||||
);
|
||||
cliBackendsTesting.setDepsForTest({
|
||||
resolvePluginSetupCliBackend: () => undefined,
|
||||
resolveRuntimeCliBackends: () => [
|
||||
{
|
||||
id: "google-gemini-cli",
|
||||
pluginId: "google",
|
||||
bundleMcp: false,
|
||||
authEpochMode: "profile-only",
|
||||
prepareExecution,
|
||||
config: {
|
||||
command: "gemini",
|
||||
args: ["--prompt", "{prompt}"],
|
||||
output: "json",
|
||||
input: "arg",
|
||||
sessionMode: "existing",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
setCliRunnerPrepareTestDeps({
|
||||
resolveApiKeyForProfile,
|
||||
});
|
||||
|
||||
try {
|
||||
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-oauth-raw-profile-fields",
|
||||
authProfileId,
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(resolveApiKeyForProfile).toHaveBeenCalledOnce();
|
||||
expect(prepareExecution).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
authProfileId,
|
||||
authCredential: expect.objectContaining({
|
||||
type: "oauth",
|
||||
provider: "google-gemini-cli",
|
||||
access: "raw-access-token",
|
||||
refresh: "raw-refresh-token",
|
||||
expires: 1_800_000_000_000,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
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, {
|
||||
|
||||
@@ -106,6 +106,7 @@ const prepareDeps = {
|
||||
prepareClaudeCliSkillsPlugin,
|
||||
claudeCliSessionTranscriptHasContent,
|
||||
claudeCliSessionTranscriptHasOrphanedToolUse,
|
||||
resolveApiKeyForProfile,
|
||||
};
|
||||
|
||||
async function resolveCliSkillsPrompt(params: {
|
||||
@@ -314,7 +315,7 @@ export async function prepareCliRunContext(
|
||||
profileId: authProfileId,
|
||||
}),
|
||||
});
|
||||
const resolvedAuth = await resolveApiKeyForProfile({
|
||||
const resolvedAuth = await prepareDeps.resolveApiKeyForProfile({
|
||||
cfg: params.config,
|
||||
store: writableAuthStore,
|
||||
profileId: authProfileId,
|
||||
@@ -329,12 +330,13 @@ export async function prepareCliRunContext(
|
||||
});
|
||||
authCredential = authStore.profiles[authProfileId];
|
||||
if (resolvedAuth && authCredential) {
|
||||
authCredential =
|
||||
authCredential.type === "api_key"
|
||||
? { ...authCredential, key: resolvedAuth.apiKey }
|
||||
: authCredential.type === "token"
|
||||
? { ...authCredential, token: resolvedAuth.apiKey }
|
||||
: { ...authCredential, access: resolvedAuth.apiKey };
|
||||
// Apply resolved strings only to static credentials with secret refs.
|
||||
// OAuth CLI bridges need raw refreshed fields from the reloaded store.
|
||||
if (authCredential.type === "api_key") {
|
||||
authCredential = { ...authCredential, key: resolvedAuth.apiKey };
|
||||
} else if (authCredential.type === "token") {
|
||||
authCredential = { ...authCredential, token: resolvedAuth.apiKey };
|
||||
}
|
||||
}
|
||||
}
|
||||
const extraSystemPrompt = params.extraSystemPrompt?.trim() ?? "";
|
||||
|
||||
Reference in New Issue
Block a user