Fix Gemini headless invocation stalls (#8368)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Local adapters turn Paperclip runs into unattended CLI invocations.
> - The `gemini_local` adapter depends on Gemini CLI behaving
non-interactively in headless worker sessions.
> - When Gemini CLI falls back to browser-based auth, the process can
stall at startup instead of producing stream-json output.
> - The adapter should make headless intent explicit and turn missing
auth into a classified, fast failure.
> - This pull request hardens the Gemini child-process environment and
parser classification around that failure mode.
> - The benefit is that operators get actionable `gemini_auth_required`
failures instead of silent hung runs.

## Linked Issues or Issue Description

No exact public issue exists for this runtime stall.

Related: #2344 covers a Gemini CLI adapter environment/auth probe
failure. This PR addresses a different runtime path: unattended
`gemini_local` executions that can stall when Gemini CLI attempts
interactive browser auth in a headless session.

Bug summary:
- Adapter: `gemini_local`
- Symptom: child Gemini CLI process starts but produces no stream-json
output when auth requires interactive browser flow
- Expected: unattended runs either produce stream-json output or fail
quickly with a classified auth error
- Actual: the run can hang at invocation until an external watchdog
kills it
- Scope: Gemini adapter process env, auth-required parsing, and adapter
docs/tests

Duplicate search performed:
- `gh pr list --state all --search "gemini headless invocation stall"`
found only this PR.
- `gh pr list --state all --search "gemini NO_BROWSER NO_COLOR"` found
only this PR.
- `gh issue list --state all --search "gemini headless authentication"`
found related issue #2344 but no exact duplicate.

## What Changed

- Set a headless-safe Gemini invocation env at the final child-process
boundary: `TERM=xterm-256color`, `COLORTERM=truecolor`, and
`NO_BROWSER=1`.
- Delete inherited `NO_COLOR` from the child env so Gemini CLI keeps
color-capable terminal behavior when deciding whether it can run
non-interactively.
- Classify Gemini `FatalAuthenticationError: Manual authorization is
required...` failures as `gemini_auth_required`.
- Update Gemini adapter docs to describe the non-interactive `--prompt`
path and headless env behavior.
- Add/extend focused parser and remote execution tests for the
auth-required and env invariants.

## Verification

- `pnpm exec vitest run
packages/adapters/gemini-local/src/server/parse.test.ts
packages/adapters/gemini-local/src/server/execute.remote.test.ts` — 23
tests passed.
- `pnpm --filter @paperclipai/adapter-gemini-local typecheck` — passed.
- Local Gemini CLI probe confirmed `NO_BROWSER=1` turns the browser-auth
stall into a fast auth error instead of an interactive wait.

## Risks

Low risk. The change is limited to `gemini_local` invocation env,
parsing, docs, and tests. It does not touch schemas, API routes,
persisted data, or other adapters.

Operational risk: environments that intentionally rely on `NO_COLOR`
being inherited by Gemini child processes will no longer pass that
variable through. That is intentional here because Gemini CLI
auth/headless behavior should not depend on inherited color suppression.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 Codex via Codex CLI, with shell/file-editing tool use for
repository inspection, code edits, tests, and GitHub PR maintenance.

## 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 not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [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
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

/cc @codex — please review.
This commit is contained in:
Sergio Sánchez Zavala
2026-06-19 21:31:36 -07:00
committed by GitHub
parent 277a9a43d6
commit 323b6220cc
5 changed files with 56 additions and 26 deletions
+2 -1
View File
@@ -60,7 +60,8 @@ Operational fields:
- graceSec (number, optional): SIGTERM grace period in seconds
Notes:
- Runs use positional prompt arguments, not stdin.
- Runs use --prompt for non-interactive execution, not stdin.
- The adapter sets a headless-safe terminal/browser environment for Gemini CLI child processes so unattended runs do not wait on browser auth or 256-color terminal prompts.
- Sessions resume with --resume when stored session cwd matches the current cwd.
- Paperclip auto-injects local skills into \`~/.gemini/skills/\` via symlinks, so the CLI can discover both credentials and skills in their natural location.
- Authentication can use GEMINI_API_KEY / GOOGLE_API_KEY or local Gemini CLI login.
@@ -126,7 +126,10 @@ describe("gemini remote execution", () => {
},
config: {
command: "gemini",
env: { GEMINI_API_KEY: "test-key" },
env: {
GEMINI_API_KEY: "test-key",
NO_COLOR: "1",
},
},
context: {
paperclipWorkspace: {
@@ -218,6 +221,10 @@ describe("gemini remote execution", () => {
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310");
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
expect(call?.[3].env.GEMINI_CLI_TRUST_WORKSPACE).toBe("true");
expect(call?.[3].env.TERM).toBe("xterm-256color");
expect(call?.[3].env.COLORTERM).toBe("truecolor");
expect(call?.[3].env.NO_BROWSER).toBe("1");
expect(call?.[3].env).not.toHaveProperty("NO_COLOR");
expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace);
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
@@ -71,6 +71,28 @@ function resolveGeminiBillingType(env: Record<string, string>): "api" | "subscri
: "subscription";
}
function buildGeminiHeadlessEnv(env: Record<string, string>): Record<string, string> {
const next = { ...env };
const term = env.TERM?.trim().toLowerCase();
if (!term || term === "dumb" || term === "vt100") {
next.TERM = "xterm-256color";
}
if (!next.COLORTERM?.trim()) {
next.COLORTERM = "truecolor";
}
next.NO_BROWSER = "1";
delete next.NO_COLOR;
return next;
}
function buildGeminiRuntimeEnv(env: Record<string, string>): Record<string, string> {
return Object.fromEntries(
Object.entries(ensurePathInEnv({ ...process.env, ...buildGeminiHeadlessEnv(env) })).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
}
function renderPaperclipEnvNote(env: Record<string, string>): string {
const paperclipKeys = Object.keys(env)
.filter((key) => key.startsWith("PAPERCLIP_"))
@@ -269,17 +291,8 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
if (!hasExplicitApiKey && authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
const effectiveEnv = Object.fromEntries(
Object.entries({ ...process.env, ...env }).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const billingType = resolveGeminiBillingType(effectiveEnv);
const runtimeEnv = Object.fromEntries(
Object.entries(ensurePathInEnv(effectiveEnv)).filter(
(entry): entry is [string, string] => typeof entry[1] === "string",
),
);
const runtimeEnv = buildGeminiRuntimeEnv(env);
const billingType = resolveGeminiBillingType(runtimeEnv);
const timeoutSec = resolveAdapterExecutionTargetTimeoutSec(
executionTarget,
asNumber(config.timeoutSec, 0),
@@ -301,12 +314,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
timeoutSec,
});
const resolvedCommand = await resolveAdapterExecutionTargetCommandForLogs(command, executionTarget, cwd, runtimeEnv);
let loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
const extraArgs = (() => {
const fromExtraArgs = asStringArray(config.extraArgs);
if (fromExtraArgs.length > 0) return fromExtraArgs;
@@ -434,11 +441,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
});
if (paperclipBridge) {
Object.assign(env, paperclipBridge.env);
loggedEnv = buildInvocationEnvForLogs(env, {
runtimeEnv: ensurePathInEnv({ ...process.env, ...env }),
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
}
}
@@ -484,6 +486,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const commandNotes = (() => {
const notes: string[] = ["Prompt is passed to Gemini via --prompt for non-interactive execution."];
notes.push("Added --approval-mode yolo for unattended execution.");
notes.push("Set headless terminal/browser env so Gemini fails fast instead of opening interactive auth or color prompts.");
if (executionTargetIsRemote) {
notes.push("Set GEMINI_CLI_TRUST_WORKSPACE=true for remote headless execution.");
}
@@ -557,6 +560,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const runAttempt = async (resumeSessionId: string | null) => {
const args = buildArgs(resumeSessionId);
const invocationEnv = buildGeminiHeadlessEnv(env);
const invocationRuntimeEnv = buildGeminiRuntimeEnv(env);
const loggedEnv = buildInvocationEnvForLogs(invocationEnv, {
runtimeEnv: invocationRuntimeEnv,
includeRuntimeKeys: ["HOME"],
resolvedCommand,
});
if (onMeta) {
await onMeta({
adapterType: "gemini_local",
@@ -575,7 +585,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
const proc = await runAdapterExecutionTargetProcess(runId, runtimeExecutionTarget, command, args, {
cwd,
env,
env: invocationEnv,
timeoutSec,
graceSec,
onSpawn,
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
detectGeminiAuthRequired,
isGeminiTransientNetworkError,
isGeminiSessionUnrecoverableError,
parseGeminiJsonl,
@@ -134,6 +135,17 @@ describe("parseGeminiJsonl", () => {
const result = parseGeminiJsonl(stdout);
expect(result.errorMessage).toBe("boom");
});
it("classifies non-interactive manual authorization failures as auth required", () => {
const result = detectGeminiAuthRequired({
parsed: null,
stdout: "",
stderr:
"Error authenticating: FatalAuthenticationError: Manual authorization is required but the current session is non-interactive.",
});
expect(result.requiresAuth).toBe(true);
});
});
describe("isGeminiSessionUnrecoverableError", () => {
@@ -263,7 +263,7 @@ export function describeGeminiFailure(parsed: Record<string, unknown>): string |
return parts.length > 1 ? parts.join(": ") : null;
}
const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i;
const GEMINI_AUTH_REQUIRED_RE = /(?:not\s+authenticated|please\s+authenticate|api[_ ]?key\s+(?:required|missing|invalid)|authentication\s+required|manual\s+authorization\s+is\s+required|unauthorized|invalid\s+credentials|not\s+logged\s+in|login\s+required|run\s+`?gemini\s+auth(?:\s+login)?`?\s+first)/i;
const GEMINI_QUOTA_EXHAUSTED_RE =
/(?:resource_exhausted|quota|rate[-\s]?limit|too many requests|\b429\b|billing details)/i;