fix(gemini-local): pre-select gemini-api-key auth in managed-HOME settings.json for headless runs (#7918)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The gemini-local adapter runs gemini-cli headlessly, including on remote/sandboxed execution targets where the adapter manages a dedicated HOME under the runtime root > - gemini-cli hard-refuses headless runs with "Invalid auth method selected." unless `$HOME/.gemini/settings.json` persists an auth selection; setting `GEMINI_DEFAULT_AUTH_TYPE` alone does NOT satisfy it (proven in an isolated pod) > - With a managed HOME the runtime root replaces the image home, so any settings.json baked into the agent image (or the user's real home) is invisible to the CLI, and every sandboxed gemini run dies before doing any work > - This affects any sandbox provider that runs gemini with API-key auth through the managed-HOME path (SSH, E2B, Daytona, Kubernetes, or any other remote execution target); it is a headless-execution bug fix, not gateway- or deployment-specific behavior > - This pull request makes the adapter pre-select the `gemini-api-key` auth type in the managed `$HOME/.gemini/settings.json` whenever a Gemini/Google API key is present, writing both settings schema generations and never touching an existing settings.json > - The benefit is that gemini agents actually run headlessly on remote and sandboxed execution targets without any manual settings provisioning ## Linked Issues or Issue Description No existing issue; describing the bug in-PR (bug template fields): - **What happened:** Headless gemini-local runs on remote/sandboxed execution targets fail immediately with `Invalid auth method selected.` even though `GEMINI_API_KEY` is provided. - **Expected:** Providing the API key should be enough for a headless run to authenticate and proceed. - **Root cause:** gemini-cli requires an auth selection persisted in `$HOME/.gemini/settings.json` for non-interactive runs; the `GEMINI_DEFAULT_AUTH_TYPE` env var does not substitute for it (verified in an isolated pod with only the env var set). The adapter's managed-HOME execution path points HOME at the runtime root, so any pre-existing settings.json (image-baked or user home) is hidden and the CLI finds no auth selection. - **Reproduction:** Run the gemini-local adapter against a remote/sandboxed execution target with `GEMINI_API_KEY` set and no settings.json under the managed HOME; the run aborts with the error above. - Duplicate/related search: no existing PR or issue addresses this; closest related is #7693 (bundles gemini-cli in the Docker image), which makes the CLI available but does not fix headless auth selection. ## What Changed - `packages/adapters/gemini-local/src/server/execute.ts`: after provisioning the managed HOME, when a Gemini/Google API key is present, write `$HOME/.gemini/settings.json` pre-selecting `gemini-api-key` auth. Both settings schema generations are written (legacy top-level `selectedAuthType` and current `security.auth.selectedType`) so old and new gemini-cli versions are covered. - The write is strictly scoped to the managed HOME (the per-run runtime root on sandbox transports). On non-managed remote targets (SSH), where the remote home is the user's real home and existing settings remain visible to the CLI, the adapter creates nothing (review feedback, P1). - The write is guarded by `[ -f ... ] ||` so a user-shipped settings.json (e.g. via workspace) is never overwritten. - The key-presence gate checks the run env AND the host process env (`GEMINI_API_KEY` / `GOOGLE_API_KEY`): in sandboxed paths the key never enters the adapter's run env; it reaches the agent pod via the sandbox provider's per-run secret (env passthrough from the host env), so the host env is the correct signal there. - `packages/adapters/gemini-local/src/server/execute.remote.test.ts`: a new sandbox-transport test asserts the settings.json write lands under the per-run runtime root (path + `gemini-api-key` content), and the SSH test asserts no settings.json is created on a non-managed home. ## Verification - `npx vitest run packages/adapters/gemini-local`: 3 files, 17 tests, all pass. - `pnpm --filter @paperclipai/adapter-gemini-local typecheck` and `build`: clean (test file is covered by the package tsconfig `include`). - Negative control: in an isolated pod, gemini-cli with `GEMINI_API_KEY` + `GEMINI_DEFAULT_AUTH_TYPE` set but no settings.json still fails with `Invalid auth method selected.`; with the settings.json written by this change, the run proceeds. - Verified end-to-end: a gemini agent in a hardened Kubernetes (gVisor) sandbox completed a real task (with `GOOGLE_GEMINI_BASE_URL` pointing at a GenAI-compatible endpoint), producing a billed usage row. That deployment supplies the verification evidence; the fix applies to any sandbox provider running gemini with API-key auth. ## Risks - Low risk. The new write only fires on the managed-HOME path (per-run runtime root) when an API key is present, and only when no settings.json exists yet, so existing setups, real user homes on SSH targets, and user-provided settings are unaffected. - If a future gemini-cli changes the settings schema again, the file may need a third generation key; both current generations are written today. ## Model Used - Claude (Anthropic), Claude Opus 4.8, 1M context, extended thinking, with tool use (code execution / shell) via Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots (no UI change) - [x] I have updated relevant documentation to reflect my changes (code comments document the behavior; no doc pages cover managed-home auth) - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups (review requested) - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
9e750d3e92
commit
69a368ed51
@@ -126,6 +126,7 @@ describe("gemini remote execution", () => {
|
||||
},
|
||||
config: {
|
||||
command: "gemini",
|
||||
env: { GEMINI_API_KEY: "test-key" },
|
||||
},
|
||||
context: {
|
||||
paperclipWorkspace: {
|
||||
@@ -184,6 +185,19 @@ describe("gemini remote execution", () => {
|
||||
expect.stringContaining(".gemini/skills"),
|
||||
expect.anything(),
|
||||
);
|
||||
// The headless-auth settings.json write is scoped to managed HOMEs (sandbox
|
||||
// transport). SSH targets keep the user's real home, where existing settings
|
||||
// stay visible and the adapter must not create files.
|
||||
expect(runSshCommand).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining(".gemini/settings.json"),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(runSshCommand).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.stringContaining("gemini-api-key"),
|
||||
expect.anything(),
|
||||
);
|
||||
const call = runChildProcess.mock.calls[0] as unknown as
|
||||
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
||||
| undefined;
|
||||
@@ -209,6 +223,77 @@ describe("gemini remote execution", () => {
|
||||
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("pre-selects gemini-api-key auth in the managed HOME for sandbox execution", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-sandbox-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
const workspaceDir = path.join(rootDir, "workspace");
|
||||
await mkdir(workspaceDir, { recursive: true });
|
||||
|
||||
const geminiOutput = [
|
||||
JSON.stringify({ type: "system", subtype: "init", session_id: "gemini-session-2", model: "gemini-2.5-pro" }),
|
||||
JSON.stringify({ type: "message", role: "assistant", content: "hello" }),
|
||||
JSON.stringify({
|
||||
type: "result",
|
||||
status: "success",
|
||||
session_id: "gemini-session-2",
|
||||
stats: { input_tokens: 1, cached_input_tokens: 0, output_tokens: 1 },
|
||||
}),
|
||||
].join("\n");
|
||||
const runnerExecute = vi.fn(async (input: { command: string; args?: string[] }) => ({
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
timedOut: false,
|
||||
stdout: input.command === "gemini" ? geminiOutput : "",
|
||||
stderr: "",
|
||||
pid: 321,
|
||||
startedAt: new Date().toISOString(),
|
||||
}));
|
||||
|
||||
await execute({
|
||||
runId: "run-sandbox-1",
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "Gemini Builder",
|
||||
adapterType: "gemini_local",
|
||||
adapterConfig: {},
|
||||
},
|
||||
runtime: {
|
||||
sessionId: null,
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: "gemini",
|
||||
env: { GEMINI_API_KEY: "test-key" },
|
||||
},
|
||||
context: {
|
||||
paperclipWorkspace: {
|
||||
cwd: workspaceDir,
|
||||
source: "project_primary",
|
||||
},
|
||||
},
|
||||
executionTarget: {
|
||||
kind: "remote",
|
||||
transport: "sandbox",
|
||||
providerKey: "kubernetes",
|
||||
remoteCwd: "/remote/workspace",
|
||||
runner: { execute: runnerExecute },
|
||||
},
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
const runnerScripts = runnerExecute.mock.calls.map(
|
||||
(call) => `${call[0].command} ${(call[0].args ?? []).join(" ")}`,
|
||||
);
|
||||
const settingsWrite = runnerScripts.find((script) => script.includes(".gemini/settings.json"));
|
||||
expect(settingsWrite).toBeDefined();
|
||||
expect(settingsWrite).toContain("gemini-api-key");
|
||||
// The managed HOME lives under the per-run runtime root, never a real home.
|
||||
expect(settingsWrite).toContain(".paperclip-runtime");
|
||||
});
|
||||
|
||||
it("resumes saved Gemini sessions for remote SSH execution only when the identity matches", async () => {
|
||||
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-gemini-remote-resume-"));
|
||||
cleanupDirs.push(rootDir);
|
||||
|
||||
@@ -356,18 +356,22 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
});
|
||||
remoteRuntimeRootDir = preparedExecutionTargetRuntime.runtimeRootDir;
|
||||
const managedHome = adapterExecutionTargetUsesManagedHome(executionTarget);
|
||||
if (managedHome && preparedExecutionTargetRuntime.runtimeRootDir) {
|
||||
env.HOME = preparedExecutionTargetRuntime.runtimeRootDir;
|
||||
const managedRemoteHomeDir =
|
||||
managedHome && preparedExecutionTargetRuntime.runtimeRootDir
|
||||
? preparedExecutionTargetRuntime.runtimeRootDir
|
||||
: null;
|
||||
if (managedRemoteHomeDir) {
|
||||
env.HOME = managedRemoteHomeDir;
|
||||
}
|
||||
const remoteHomeDir = managedHome && preparedExecutionTargetRuntime.runtimeRootDir
|
||||
? preparedExecutionTargetRuntime.runtimeRootDir
|
||||
: await readAdapterExecutionTargetHomeDir(runId, executionTarget, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
});
|
||||
const remoteHomeDir =
|
||||
managedRemoteHomeDir ??
|
||||
(await readAdapterExecutionTargetHomeDir(runId, executionTarget, {
|
||||
cwd,
|
||||
env,
|
||||
timeoutSec,
|
||||
graceSec,
|
||||
onLog,
|
||||
}));
|
||||
if (remoteHomeDir && preparedExecutionTargetRuntime.assetDirs.skills) {
|
||||
remoteSkillsDir = path.posix.join(remoteHomeDir, ".gemini", "skills");
|
||||
await runAdapterExecutionTargetShellCommand(
|
||||
@@ -377,6 +381,38 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
|
||||
{ cwd, env, timeoutSec, graceSec, onLog },
|
||||
);
|
||||
}
|
||||
// Gemini CLI refuses headless runs without an auth selection persisted in
|
||||
// $HOME/.gemini/settings.json ("Invalid auth method selected."); env vars
|
||||
// alone (GEMINI_DEFAULT_AUTH_TYPE) do not satisfy it. With a managed HOME
|
||||
// the runtime root replaces the image home, so any settings baked into the
|
||||
// image are invisible -- pre-select the api-key auth whenever an API key
|
||||
// is provided. Both settings schema generations are written (legacy
|
||||
// selectedAuthType + current security.auth.selectedType). An existing
|
||||
// settings.json (user-shipped via workspace) is left untouched.
|
||||
// Only the managed HOME (the per-run runtime root) is touched: on
|
||||
// non-managed remote targets remoteHomeDir is the user's real home, where
|
||||
// creating files is out of scope and existing settings remain visible.
|
||||
// Key presence check spans the run env AND the host process env: in the
|
||||
// managed sandbox path the key never enters the adapter's run env -- it
|
||||
// reaches the agent pod via the provider's per-run secret (envKeys
|
||||
// passthrough from the host env), so the host env is the signal here.
|
||||
const hasGeminiApiKey = Boolean(
|
||||
env.GEMINI_API_KEY || env.GOOGLE_API_KEY ||
|
||||
process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY,
|
||||
);
|
||||
if (managedRemoteHomeDir && hasGeminiApiKey) {
|
||||
const remoteSettingsPath = path.posix.join(managedRemoteHomeDir, ".gemini", "settings.json");
|
||||
const authSettingsJson = JSON.stringify({
|
||||
selectedAuthType: "gemini-api-key",
|
||||
security: { auth: { selectedType: "gemini-api-key" } },
|
||||
});
|
||||
await runAdapterExecutionTargetShellCommand(
|
||||
runId,
|
||||
executionTarget,
|
||||
`mkdir -p ${JSON.stringify(path.posix.dirname(remoteSettingsPath))} && { [ -f ${JSON.stringify(remoteSettingsPath)} ] || printf '%s' ${JSON.stringify(authSettingsJson)} > ${JSON.stringify(remoteSettingsPath)}; }`,
|
||||
{ cwd, env, timeoutSec, graceSec, onLog },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
await Promise.allSettled([
|
||||
restoreRemoteWorkspace?.(),
|
||||
|
||||
Reference in New Issue
Block a user