fix(gemini): stage CLI auth inside run queue

Fixes #98945
This commit is contained in:
Ayaan Zaidi 2026-07-01 23:37:34 -07:00
parent cc5804868f
commit 5fc9b20d24
9 changed files with 236 additions and 37 deletions

View file

@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type { CliBackendPreparedExecution } from "openclaw/plugin-sdk/cli-backend";
@ -254,10 +255,16 @@ async function buildGeminiCliSystemSettings(
}
async function writeGeminiCliJson(filePath: string, value: unknown): Promise<void> {
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
const tempPath = path.join(
path.dirname(filePath),
`.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`,
);
await fs.writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, {
encoding: "utf8",
mode: 0o600,
});
await fs.chmod(tempPath, 0o600);
await fs.rename(tempPath, filePath);
await fs.chmod(filePath, 0o600);
}
@ -268,12 +275,10 @@ async function prepareGeminiCliProfileHome(
home: string;
geminiDir: string;
systemSettingsPath: string;
beforeExecution: () => Promise<void>;
cleanup: () => Promise<void>;
}> {
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 systemSettingsDir = await fs.mkdtemp(
@ -281,20 +286,20 @@ async function prepareGeminiCliProfileHome(
);
await fs.chmod(systemSettingsDir, 0o700);
const systemSettingsPath = path.join(systemSettingsDir, "settings.json");
try {
await Promise.all([
writeGeminiCliJson(path.join(geminiDir, "settings.json"), settings),
writeGeminiCliJson(path.join(home, "settings.json"), settings),
writeGeminiCliJson(systemSettingsPath, systemSettings),
]);
} catch (error) {
await fs.rm(systemSettingsDir, { recursive: true, force: true });
throw error;
}
return {
home,
geminiDir,
systemSettingsPath,
beforeExecution: async () => {
await fs.mkdir(geminiDir, { recursive: true, mode: 0o700 });
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),
writeGeminiCliJson(systemSettingsPath, systemSettings),
]);
},
cleanup: async () => {
await fs.rm(systemSettingsDir, { recursive: true, force: true });
},
@ -328,11 +333,7 @@ async function prepareGeminiCliOAuthHome(
return null;
}
const { home, geminiDir, systemSettingsPath, cleanup } = await prepareGeminiCliProfileHome(
ctx,
"oauth-personal",
);
await clearGeminiCliCachedCredentials(geminiDir);
const profileHome = await prepareGeminiCliProfileHome(ctx, "oauth-personal");
const idToken = normalizeString(oauth.idToken);
const oauthCreds: Record<string, string | number> = {
access_token: oauth.access,
@ -344,17 +345,20 @@ async function prepareGeminiCliOAuthHome(
oauthCreds.id_token = idToken;
}
await writeGeminiCliJson(path.join(geminiDir, "oauth_creds.json"), oauthCreds);
return {
env: {
GEMINI_CLI_HOME: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath,
GEMINI_CLI_HOME: profileHome.home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: profileHome.systemSettingsPath,
GEMINI_FORCE_FILE_STORAGE: "true",
...buildGeminiCliProjectEnv(oauth.projectId),
},
clearEnv: [...GEMINI_CLI_PROFILE_AUTH_ENV, ...GEMINI_CLI_PROFILE_SETTINGS_ENV],
cleanup,
beforeExecution: async () => {
await profileHome.beforeExecution();
await clearGeminiCliCachedCredentials(profileHome.geminiDir);
await writeGeminiCliJson(path.join(profileHome.geminiDir, "oauth_creds.json"), oauthCreds);
},
cleanup: profileHome.cleanup,
};
}
@ -367,23 +371,23 @@ async function prepareGeminiCliApiKeyHome(
return null;
}
const { home, geminiDir, systemSettingsPath, cleanup } = await prepareGeminiCliProfileHome(
ctx,
"gemini-api-key",
);
await Promise.all([
fs.rm(path.join(geminiDir, "oauth_creds.json"), { force: true }),
clearGeminiCliCachedCredentials(geminiDir),
]);
const profileHome = await prepareGeminiCliProfileHome(ctx, "gemini-api-key");
return {
env: {
GEMINI_CLI_HOME: home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: systemSettingsPath,
GEMINI_CLI_HOME: profileHome.home,
GEMINI_CLI_SYSTEM_SETTINGS_PATH: profileHome.systemSettingsPath,
GEMINI_FORCE_FILE_STORAGE: "true",
GEMINI_API_KEY: apiKey.key,
},
clearEnv: [...GEMINI_CLI_PROFILE_AUTH_ENV, ...GEMINI_CLI_PROFILE_SETTINGS_ENV],
cleanup,
beforeExecution: async () => {
await profileHome.beforeExecution();
await Promise.all([
fs.rm(path.join(profileHome.geminiDir, "oauth_creds.json"), { force: true }),
clearGeminiCliCachedCredentials(profileHome.geminiDir),
]);
},
cleanup: profileHome.cleanup,
};
}

View file

@ -4,7 +4,8 @@ import path from "node:path";
import type { CliBackendPlugin } from "openclaw/plugin-sdk/cli-backend";
import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { describe, expect, it } from "vitest";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it, vi } from "vitest";
import { buildGoogleGeminiCliBackend } from "./cli-backend.js";
import setupEntry from "./setup-api.js";
@ -24,6 +25,15 @@ type GeminiPrepareContext = Parameters<
email?: string;
};
};
type GeminiPreparedExecution = Awaited<
ReturnType<NonNullable<ReturnType<typeof buildGoogleGeminiCliBackend>["prepareExecution"]>>
>;
async function stageGeminiPreparedExecution(
prepared: GeminiPreparedExecution | null | undefined,
): Promise<void> {
await prepared?.beforeExecution?.();
}
function buildGeminiOAuthPrepareContext(workspaceDir: string): GeminiPrepareContext {
const agentDir = path.join(workspaceDir, "agent");
@ -164,6 +174,7 @@ describe("google gemini cli backend auth bridge", () => {
if (prepared?.cleanup) {
cleanups.push(prepared.cleanup);
}
await stageGeminiPreparedExecution(prepared);
home = prepared?.env?.GEMINI_CLI_HOME;
const systemSettingsPath = prepared?.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
@ -223,6 +234,7 @@ describe("google gemini cli backend auth bridge", () => {
if (preparedAgain?.cleanup) {
cleanups.push(preparedAgain.cleanup);
}
await stageGeminiPreparedExecution(preparedAgain);
expect(preparedAgain?.env?.GEMINI_CLI_HOME).toBe(home);
await expect(fs.access(sessionMarker)).resolves.toBeUndefined();
await expect(fs.access(cachedCredentialsPath)).rejects.toThrow();
@ -234,6 +246,49 @@ describe("google gemini cli backend auth bridge", () => {
}
});
it("stages Gemini CLI JSON through same-directory atomic renames", async () => {
await withTempDir("openclaw-test-workspace-", async (workspaceDir) => {
const backend = buildGoogleGeminiCliBackend();
const realRename = fs.rename.bind(fs);
const renameCalls: Array<{ from: string; to: string }> = [];
const renameSpy = vi
.spyOn(fs, "rename")
.mockImplementation(async (...args: Parameters<typeof fs.rename>) => {
renameCalls.push({ from: String(args[0]), to: String(args[1]) });
await realRename(...args);
});
let prepared: GeminiPreparedExecution | null | undefined;
try {
prepared = await backend.prepareExecution?.(buildGeminiOAuthPrepareContext(workspaceDir));
await stageGeminiPreparedExecution(prepared);
const home = prepared?.env?.GEMINI_CLI_HOME;
const systemSettingsPath = prepared?.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH;
if (!home || !systemSettingsPath) {
throw new Error("expected Gemini CLI staging paths");
}
const expectedTargets = [
path.join(home, ".gemini", "settings.json"),
path.join(home, "settings.json"),
systemSettingsPath,
path.join(home, ".gemini", "oauth_creds.json"),
];
expect(renameCalls.map((call) => call.to).toSorted()).toEqual(expectedTargets.toSorted());
for (const call of renameCalls) {
expect(path.dirname(call.from)).toBe(path.dirname(call.to));
expect(path.basename(call.from).startsWith(`.${path.basename(call.to)}.`)).toBe(true);
expect(path.basename(call.from).endsWith(".tmp")).toBe(true);
}
const oauthStat = await fs.stat(path.join(home, ".gemini", "oauth_creds.json"));
expect(oauthStat.mode & 0o777).toBe(0o600);
} finally {
renameSpy.mockRestore();
await prepared?.cleanup?.();
}
});
});
it("prepares selected canonical Google 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-"));
@ -246,6 +301,7 @@ describe("google gemini cli backend auth bridge", () => {
if (firstPrepared?.cleanup) {
cleanups.push(firstPrepared.cleanup);
}
await stageGeminiPreparedExecution(firstPrepared);
home = firstPrepared?.env?.GEMINI_CLI_HOME;
expect(home).toBeTruthy();
await fs.writeFile(path.join(home ?? "", ".gemini", "oauth_creds.json"), "{}\n", "utf8");
@ -259,6 +315,7 @@ describe("google gemini cli backend auth bridge", () => {
if (prepared?.cleanup) {
cleanups.push(prepared.cleanup);
}
await stageGeminiPreparedExecution(prepared);
home = prepared?.env?.GEMINI_CLI_HOME;
expect(home).toBeTruthy();
@ -339,6 +396,7 @@ describe("google gemini cli backend auth bridge", () => {
process.env.GEMINI_CLI_SYSTEM_SETTINGS_PATH = inheritedSettingsPath;
prepared = await backend.prepareExecution?.(buildGeminiOAuthPrepareContext(workspaceDir));
await stageGeminiPreparedExecution(prepared);
const systemSettingsRaw = await fs.readFile(
prepared?.env?.GEMINI_CLI_SYSTEM_SETTINGS_PATH ?? "",

View file

@ -24,6 +24,7 @@ import { writeGeminiMcpCaptureSettings, writeGeminiSystemSettings } from "./bund
type PreparedCliBundleMcpConfig = {
backend: CliBackendConfig;
beforeExecution?: () => Promise<void>;
cleanup?: () => Promise<void>;
mcpConfigHash?: string;
mcpResumeHash?: string;

View file

@ -19,6 +19,20 @@ import type { PreparedCliRunContext } from "./types.js";
type ProcessSupervisor = ReturnType<typeof getProcessSupervisor>;
type SupervisorSpawnInput = Parameters<ProcessSupervisor["spawn"]>[0];
function createDeferred<T = void>(): {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
} {
let resolve: (value: T | PromiseLike<T>) => void = () => {};
let reject: (reason?: unknown) => void = () => {};
const promise = new Promise<T>((promiseResolve, promiseReject) => {
resolve = promiseResolve;
reject = promiseReject;
});
return { promise, resolve, reject };
}
function recordMcpLoopbackToolCallResult(params: {
captureKey: string;
toolName: string;
@ -43,6 +57,8 @@ function recordMcpLoopbackToolCallResult(params: {
function buildPreparedCliRunContext(params: {
output: "jsonl" | "text";
provider?: string;
runId?: string;
beforeExecution?: () => Promise<void>;
}): PreparedCliRunContext {
const provider = params.provider ?? "codex-cli";
const backend = {
@ -62,7 +78,7 @@ function buildPreparedCliRunContext(params: {
provider,
model: "model",
timeoutMs: 1_000,
runId: `run-${params.output}`,
runId: params.runId ?? `run-${params.output}`,
},
started: Date.now(),
workspaceDir: "/tmp",
@ -74,6 +90,7 @@ function buildPreparedCliRunContext(params: {
preparedBackend: {
backend,
env: {},
...(params.beforeExecution ? { beforeExecution: params.beforeExecution } : {}),
},
reusableCliSession: {},
hadSessionFile: false,
@ -101,6 +118,65 @@ beforeEach(() => {
});
describe("executePreparedCliRun supervisor output capture", () => {
it("runs prepared backend staging inside the serialized execution queue", async () => {
const firstSpawnEntered = createDeferred();
const releaseFirstSpawn = createDeferred();
const events: string[] = [];
let spawnCount = 0;
supervisorSpawnMock.mockImplementation(async (...args: unknown[]) => {
spawnCount += 1;
const input = args[0] as SupervisorSpawnInput;
const label = spawnCount === 1 ? "first" : "second";
events.push(`spawn:${label}`);
input.onStdout?.(`answer ${label}`);
if (label === "first") {
firstSpawnEntered.resolve();
await releaseFirstSpawn.promise;
}
return createManagedRun({
reason: "exit",
exitCode: 0,
exitSignal: null,
durationMs: 50,
stdout: "",
stderr: "",
timedOut: false,
noOutputTimedOut: false,
});
});
const first = executePreparedCliRun(
buildPreparedCliRunContext({
output: "text",
runId: "run-first",
beforeExecution: async () => {
events.push("stage:first");
},
}),
);
await firstSpawnEntered.promise;
const second = executePreparedCliRun(
buildPreparedCliRunContext({
output: "text",
runId: "run-second",
beforeExecution: async () => {
events.push("stage:second");
},
}),
);
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(events).toEqual(["stage:first", "spawn:first"]);
releaseFirstSpawn.resolve();
await Promise.all([first, second]);
expect(events).toEqual(["stage:first", "spawn:first", "stage:second", "spawn:second"]);
});
it("disables supervisor capture without parsing from the diagnostic stdout tail", async () => {
const fullText = `start-${"x".repeat(80 * 1024)}-end`;

View file

@ -568,6 +568,7 @@ export async function executePreparedCliRun(
if (params.lifecycleGeneration) {
assertAgentRunLifecycleGenerationCurrent(params.lifecycleGeneration);
}
await context.preparedBackend.beforeExecution?.();
const cliTurnStartedAt = Date.now();
const restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({

View file

@ -852,6 +852,51 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
}
});
it("preserves backend staging for queued execution without running it during prepare", async () => {
const { dir, sessionFile } = createSessionFile();
const beforeExecution = vi.fn(async () => {});
const prepareExecution = vi.fn(async () => ({ beforeExecution }));
cliBackendsTesting.setDepsForTest({
resolvePluginSetupCliBackend: () => undefined,
resolveRuntimeCliBackends: () => [
{
id: "test-cli",
pluginId: "test-plugin",
bundleMcp: false,
prepareExecution,
config: {
command: "test-cli",
args: ["--print"],
sessionMode: "existing",
output: "text",
input: "arg",
},
},
],
});
try {
const context = await prepareCliRunContext({
sessionId: "session-test",
sessionFile,
workspaceDir: dir,
prompt: "latest ask",
provider: "test-cli",
model: "test-model",
timeoutMs: 1_000,
runId: "run-test-staging-thunk",
config: createCliBackendConfig(),
});
expect(prepareExecution).toHaveBeenCalledOnce();
expect(beforeExecution).not.toHaveBeenCalled();
await context.preparedBackend.beforeExecution?.();
expect(beforeExecution).toHaveBeenCalledOnce();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
it("cleans generated Gemini MCP settings when auth preparation fails", async () => {
const { dir, sessionFile } = createSessionFile();
let generatedSystemSettingsPath: string | undefined;

View file

@ -530,6 +530,13 @@ export async function prepareCliRunContext(
}
}
: undefined;
const preparedBackendBeforeExecution =
preparedBackend.beforeExecution || preparedExecution?.beforeExecution
? async () => {
await preparedBackend.beforeExecution?.();
await preparedExecution?.beforeExecution?.();
}
: undefined;
const claudeSkillsPlugin = isSideQuestion
? { args: [], cleanup: async () => {} }
: await prepareDeps.prepareClaudeCliSkillsPlugin({
@ -566,6 +573,7 @@ export async function prepareCliRunContext(
: {}),
},
...(preparedBackendEnv ? { env: preparedBackendEnv } : {}),
...(preparedBackendBeforeExecution ? { beforeExecution: preparedBackendBeforeExecution } : {}),
...(preparedCleanup ? { cleanup: preparedCleanup } : {}),
};
const promptTools =

View file

@ -159,6 +159,7 @@ export type RunCliAgentParams = {
/** Backend config after MCP, skill, env, and cleanup preparation. */
export type CliPreparedBackend = {
backend: CliBackendConfig;
beforeExecution?: () => Promise<void>;
cleanup?: () => Promise<void>;
mcpConfigHash?: string;
mcpResumeHash?: string;

View file

@ -33,6 +33,11 @@ export type CliBackendPrepareExecutionContext = {
export type CliBackendPreparedExecution = {
env?: Record<string, string>;
clearEnv?: string[];
/**
* Backend-owned staging that must run after the core CLI queue admits the turn.
* Use this for mutable per-profile CLI homes that the launched process also owns.
*/
beforeExecution?: () => Promise<void>;
cleanup?: () => Promise<void>;
};