Skip to content

Commit 85707ab

Browse files
committed
fix(gemini): resolve hanging background git push and process leaks in oauth login
This commit addresses the issue where running 'docker-git auth gemini login --web' would hang. The root causes were twofold: 1. The internal `runCommandExitCode` function tied stdin to 'inherit', which meant background silent git commands (like 'git push' during state auto-sync) would block waiting for password/username if HTTPS was used. 2. Gemini OAuth CLI runner left background stdout/stderr tailing routines running when the docker process was killed. Fixes included: - Adding --init to gemini oauth docker execution to cleanly handle process termination - Changing pipe modes in `runCommandExitCode` to fail correctly if standard input is needed - Adding GIT_SSH_COMMAND="ssh -o BatchMode=yes" for background state synchronization to fail rather than hanging on unknown hosts - Splitting `auth-gemini.ts` to resolve file size linting limits - Adding some test cases for the command builder and environment variables
1 parent d8aabd5 commit 85707ab

File tree

10 files changed

+412
-305
lines changed

10 files changed

+412
-305
lines changed

packages/app/src/docker-git/menu-auth-effects.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import {
77
authGeminiLoginOauth,
88
authGeminiLogout,
99
authGithubLogin,
10-
claudeAuthRoot,
11-
geminiAuthRoot
10+
claudeAuthRoot
1211
} from "@effect-template/lib/usecases/auth"
12+
import { geminiAuthRoot } from "@effect-template/lib/usecases/auth-gemini-helpers"
1313
import type { AppError } from "@effect-template/lib/usecases/errors"
1414
import { renderError } from "@effect-template/lib/usecases/errors"
1515

packages/lib/src/shell/command-runner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ export const runCommandExitCode = (
6262
): Effect.Effect<number, PlatformError, CommandExecutor.CommandExecutor> =>
6363
Effect.map(
6464
Command.exitCode(
65-
buildCommand(spec, "pipe", "pipe", "inherit")
65+
buildCommand(spec, "pipe", "pipe", "pipe")
6666
),
6767
Number
6868
)
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
2+
import type { PlatformError } from "@effect/platform/Error"
3+
import type * as FileSystem from "@effect/platform/FileSystem"
4+
import type * as Path from "@effect/platform/Path"
5+
import { Effect, pipe } from "effect"
6+
7+
import type { AuthGeminiLoginCommand, AuthGeminiLogoutCommand, AuthGeminiStatusCommand } from "../core/domain.js"
8+
import { defaultTemplateConfig } from "../core/domain.js"
9+
import { runCommandExitCode } from "../shell/command-runner.js"
10+
import type { CommandFailedError } from "../shell/errors.js"
11+
import { isRegularFile, normalizeAccountLabel } from "./auth-helpers.js"
12+
import { migrateLegacyOrchLayout } from "./auth-sync.js"
13+
import { ensureDockerImage } from "./docker-image.js"
14+
import { resolvePathFromCwd } from "./path-helpers.js"
15+
import { withFsPathContext } from "./runtime.js"
16+
17+
export type GeminiRuntime = FileSystem.FileSystem | Path.Path | CommandExecutor.CommandExecutor
18+
export type GeminiAuthMethod = "none" | "api-key" | "oauth"
19+
20+
export const geminiImageName = "docker-git-auth-gemini:latest"
21+
export const geminiImageDir = ".docker-git/.orch/auth/gemini/.image"
22+
export const geminiContainerHomeDir = "/gemini-home"
23+
export const geminiCredentialsDir = ".gemini"
24+
25+
export type GeminiAccountContext = {
26+
readonly accountLabel: string
27+
readonly accountPath: string
28+
readonly cwd: string
29+
readonly fs: FileSystem.FileSystem
30+
}
31+
32+
export const geminiAuthRoot = ".docker-git/.orch/auth/gemini"
33+
34+
export const geminiApiKeyFileName = ".api-key"
35+
export const geminiEnvFileName = ".env"
36+
37+
export const geminiApiKeyPath = (accountPath: string): string => `${accountPath}/${geminiApiKeyFileName}`
38+
export const geminiEnvFilePath = (accountPath: string): string => `${accountPath}/${geminiEnvFileName}`
39+
export const geminiCredentialsPath = (accountPath: string): string => `${accountPath}/${geminiCredentialsDir}`
40+
41+
// CHANGE: render Dockerfile for Gemini CLI authentication image
42+
// WHY: Gemini CLI OAuth requires running in Docker for headless environments
43+
// QUOTE(ТЗ): "Типо ждал пока мы вставим ссылку"
44+
// REF: issue-146, PR-147 comment
45+
// SOURCE: https://github.com/google-gemini/gemini-cli
46+
// FORMAT THEOREM: renderGeminiDockerfile() -> valid_dockerfile
47+
// PURITY: CORE
48+
// INVARIANT: Image includes Node.js and Gemini CLI
49+
// COMPLEXITY: O(1)
50+
export const renderGeminiDockerfile = (): string =>
51+
String.raw`FROM ubuntu:24.04
52+
ENV DEBIAN_FRONTEND=noninteractive
53+
RUN apt-get update \
54+
&& apt-get install -y --no-install-recommends ca-certificates curl bsdutils \
55+
&& rm -rf /var/lib/apt/lists/*
56+
RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \
57+
&& apt-get install -y --no-install-recommends nodejs \
58+
&& rm -rf /var/lib/apt/lists/*
59+
RUN npm install -g @google/gemini-cli@0.33.2
60+
`
61+
62+
export const ensureGeminiOrchLayout = (
63+
cwd: string
64+
): Effect.Effect<void, PlatformError, FileSystem.FileSystem | Path.Path> =>
65+
migrateLegacyOrchLayout(cwd, {
66+
envGlobalPath: defaultTemplateConfig.envGlobalPath,
67+
envProjectPath: defaultTemplateConfig.envProjectPath,
68+
codexAuthPath: defaultTemplateConfig.codexAuthPath,
69+
ghAuthPath: ".docker-git/.orch/auth/gh",
70+
claudeAuthPath: ".docker-git/.orch/auth/claude",
71+
geminiAuthPath: ".docker-git/.orch/auth/gemini"
72+
})
73+
74+
export const resolveGeminiAccountPath = (path: Path.Path, rootPath: string, label: string | null): {
75+
readonly accountLabel: string
76+
readonly accountPath: string
77+
} => {
78+
const accountLabel = normalizeAccountLabel(label, "default")
79+
const accountPath = path.join(rootPath, accountLabel)
80+
return { accountLabel, accountPath }
81+
}
82+
83+
export const withGeminiAuth = <A, E>(
84+
command: AuthGeminiLoginCommand | AuthGeminiLogoutCommand | AuthGeminiStatusCommand,
85+
run: (
86+
context: GeminiAccountContext
87+
) => Effect.Effect<A, E, CommandExecutor.CommandExecutor>,
88+
options: { readonly buildImage?: boolean } = {}
89+
): Effect.Effect<A, E | PlatformError | CommandFailedError, GeminiRuntime> =>
90+
withFsPathContext(({ cwd, fs, path }) =>
91+
Effect.gen(function*(_) {
92+
yield* _(ensureGeminiOrchLayout(cwd))
93+
const rootPath = resolvePathFromCwd(path, cwd, command.geminiAuthPath)
94+
const { accountLabel, accountPath } = resolveGeminiAccountPath(path, rootPath, command.label)
95+
yield* _(fs.makeDirectory(accountPath, { recursive: true }))
96+
if (options.buildImage === true) {
97+
yield* _(
98+
ensureDockerImage(fs, path, cwd, {
99+
imageName: geminiImageName,
100+
imageDir: geminiImageDir,
101+
dockerfile: renderGeminiDockerfile(),
102+
buildLabel: "gemini auth"
103+
})
104+
)
105+
}
106+
return yield* _(run({ accountLabel, accountPath, cwd, fs }))
107+
})
108+
)
109+
110+
export const readApiKey = (
111+
fs: FileSystem.FileSystem,
112+
accountPath: string
113+
): Effect.Effect<string | null, PlatformError> =>
114+
Effect.gen(function*(_) {
115+
const apiKeyFilePath = geminiApiKeyPath(accountPath)
116+
const hasApiKey = yield* _(isRegularFile(fs, apiKeyFilePath))
117+
if (hasApiKey) {
118+
const apiKey = yield* _(fs.readFileString(apiKeyFilePath), Effect.orElseSucceed(() => ""))
119+
const trimmed = apiKey.trim()
120+
if (trimmed.length > 0) {
121+
return trimmed
122+
}
123+
}
124+
125+
const envFilePath = geminiEnvFilePath(accountPath)
126+
const hasEnvFile = yield* _(isRegularFile(fs, envFilePath))
127+
if (hasEnvFile) {
128+
const envContent = yield* _(fs.readFileString(envFilePath), Effect.orElseSucceed(() => ""))
129+
const lines = envContent.split("\n")
130+
for (const line of lines) {
131+
const trimmed = line.trim()
132+
if (trimmed.startsWith("GEMINI_API_KEY=")) {
133+
const value = trimmed.slice("GEMINI_API_KEY=".length).replaceAll(/^['"]|['"]$/g, "").trim()
134+
if (value.length > 0) {
135+
return value
136+
}
137+
}
138+
}
139+
}
140+
141+
return null
142+
})
143+
144+
// CHANGE: check for OAuth credentials in .gemini directory
145+
// WHY: Gemini CLI stores OAuth tokens in ~/.gemini after successful OAuth flow
146+
// QUOTE(ТЗ): "Типо ждал пока мы вставим ссылку"
147+
// REF: issue-146, PR-147 comment
148+
// SOURCE: https://github.com/google-gemini/gemini-cli
149+
// FORMAT THEOREM: hasOauthCredentials(fs, accountPath) -> boolean
150+
// PURITY: SHELL
151+
// INVARIANT: checks for existence of OAuth token file
152+
// COMPLEXITY: O(1)
153+
export const hasOauthCredentials = (
154+
fs: FileSystem.FileSystem,
155+
accountPath: string
156+
): Effect.Effect<boolean, PlatformError> =>
157+
Effect.gen(function*(_) {
158+
const credentialsDir = geminiCredentialsPath(accountPath)
159+
const dirExists = yield* _(fs.exists(credentialsDir))
160+
if (!dirExists) {
161+
return false
162+
}
163+
// Check for various possible credential files Gemini CLI might create
164+
const possibleFiles = [
165+
`${credentialsDir}/oauth-tokens.json`,
166+
`${credentialsDir}/credentials.json`,
167+
`${credentialsDir}/application_default_credentials.json`
168+
]
169+
for (const filePath of possibleFiles) {
170+
const fileExists = yield* _(isRegularFile(fs, filePath))
171+
if (fileExists) {
172+
return true
173+
}
174+
}
175+
return false
176+
})
177+
178+
// CHANGE: resolve Gemini authentication method
179+
// WHY: need to detect whether user authenticated via API key or OAuth
180+
// QUOTE(ТЗ): "Добавь поддержку gemini CLI"
181+
// REF: issue-146
182+
// SOURCE: https://geminicli.com/docs/get-started/authentication/
183+
// FORMAT THEOREM: resolveGeminiAuthMethod(fs, accountPath) -> GeminiAuthMethod
184+
// PURITY: SHELL
185+
// INVARIANT: API key takes precedence over OAuth credentials
186+
// COMPLEXITY: O(1)
187+
export const resolveGeminiAuthMethod = (
188+
fs: FileSystem.FileSystem,
189+
accountPath: string
190+
): Effect.Effect<GeminiAuthMethod, PlatformError> =>
191+
Effect.gen(function*(_) {
192+
const apiKey = yield* _(readApiKey(fs, accountPath))
193+
if (apiKey !== null) {
194+
return "api-key"
195+
}
196+
197+
const hasOauth = yield* _(hasOauthCredentials(fs, accountPath))
198+
return hasOauth ? "oauth" : "none"
199+
})
200+
201+
// CHANGE: login to Gemini CLI via OAuth in Docker container
202+
// WHY: enable Gemini CLI OAuth authentication in headless/Docker environments
203+
// QUOTE(ТЗ): "Мне надо что бы он её умел принимать, типо ждал пока мы вставим ссылку"
204+
// REF: issue-146, PR-147 comment from skulidropek
205+
// SOURCE: https://github.com/google-gemini/gemini-cli
206+
export const prepareGeminiCredentialsDir = (
207+
cwd: string,
208+
accountPath: string,
209+
fs: FileSystem.FileSystem
210+
) =>
211+
Effect.gen(function*(_) {
212+
const credentialsDir = geminiCredentialsPath(accountPath)
213+
const removeFallback = pipe(
214+
runCommandExitCode({
215+
cwd,
216+
command: "docker",
217+
args: ["run", "--rm", "-v", `${accountPath}:/target`, "alpine", "rm", "-rf", "/target/.gemini"]
218+
}),
219+
Effect.asVoid,
220+
Effect.orElse(() => Effect.void)
221+
)
222+
223+
yield* _(
224+
fs.remove(credentialsDir, { recursive: true, force: true }).pipe(
225+
Effect.orElse(() => removeFallback)
226+
)
227+
)
228+
yield* _(fs.makeDirectory(credentialsDir, { recursive: true }))
229+
// Fix permissions before Docker starts, so root in container can write freely
230+
yield* _(
231+
runCommandExitCode({
232+
cwd,
233+
command: "chmod",
234+
args: ["-R", "777", credentialsDir]
235+
}).pipe(Effect.orElse(() => Effect.succeed(0)))
236+
)
237+
return credentialsDir
238+
})
239+
240+
export const writeInitialSettings = (credentialsDir: string, fs: FileSystem.FileSystem) =>
241+
Effect.gen(function*(_) {
242+
const settingsPath = `${credentialsDir}/settings.json`
243+
yield* _(
244+
fs.writeFileString(
245+
settingsPath,
246+
JSON.stringify(
247+
{
248+
model: {
249+
name: "gemini-2.0-flash",
250+
compressionThreshold: 0.9,
251+
disableLoopDetection: true
252+
},
253+
general: {
254+
defaultApprovalMode: "auto_edit"
255+
},
256+
yolo: true,
257+
sandbox: {
258+
enabled: false
259+
},
260+
security: {
261+
folderTrust: { enabled: false },
262+
auth: { selectedType: "oauth-personal" },
263+
approvalPolicy: "never"
264+
}
265+
},
266+
null,
267+
2
268+
)
269+
)
270+
)
271+
272+
const trustedFoldersPath = `${credentialsDir}/trustedFolders.json`
273+
yield* _(
274+
fs.writeFileString(
275+
trustedFoldersPath,
276+
JSON.stringify({ "/": "TRUST_FOLDER", [geminiContainerHomeDir]: "TRUST_FOLDER" })
277+
)
278+
)
279+
return settingsPath
280+
})

packages/lib/src/usecases/auth-gemini-oauth.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,7 @@ const authSuccessPatterns = [
3232
"Authentication successful",
3333
"Successfully authenticated",
3434
"Logged in as",
35-
"You are now logged in",
36-
"Logged in with Google"
35+
"You are now logged in"
3736
]
3837

3938
const authFailurePatterns = [
@@ -100,22 +99,27 @@ const buildDockerGeminiAuthSpec = (
10099
"NO_BROWSER=true",
101100
"GEMINI_CLI_NONINTERACTIVE=true",
102101
"GEMINI_CLI_TRUST_ALL=true",
102+
"GEMINI_DISABLE_UPDATE_CHECK=true",
103103
`OAUTH_CALLBACK_PORT=${port}`,
104104
"OAUTH_CALLBACK_HOST=0.0.0.0"
105105
]
106106
})
107-
108-
const buildDockerGeminiAuthArgs = (spec: DockerGeminiAuthSpec): ReadonlyArray<string> => {
107+
export const buildDockerGeminiAuthArgs = (spec: DockerGeminiAuthSpec): ReadonlyArray<string> => {
109108
const base: Array<string> = [
110109
"run",
111110
"--rm",
111+
"--init",
112112
"-i",
113113
"-t",
114114
"-v",
115115
`${spec.hostPath}:${spec.containerPath}`,
116116
"-p",
117-
`${spec.callbackPort}:${spec.callbackPort}`
117+
`${spec.callbackPort}:${spec.callbackPort}`,
118+
"-w",
119+
spec.containerPath
118120
]
121+
// ...
122+
119123
// NOTE: Running as root inside the auth container to ensure access to all internal paths.
120124
// The mounted volume will still be accessible, and credentials will be written there.
121125
for (const entry of spec.env) {
@@ -125,10 +129,9 @@ const buildDockerGeminiAuthArgs = (spec: DockerGeminiAuthSpec): ReadonlyArray<st
125129
}
126130
base.push("-e", trimmed)
127131
}
128-
// Run gemini CLI with --debug flag to ensure auth URL is shown
129-
// WHY: In some Gemini CLI versions, auth URL is only shown with --debug flag
130-
// SOURCE: https://github.com/google-gemini/gemini-cli/issues/13853
131-
return [...base, spec.image, "gemini", "login", "--debug"]
132+
// Run gemini mcp list with --debug flag to ensure auth URL is shown
133+
// WHY: In some Gemini CLI versions, auth URL is only shown with --debug flag, and 'login' is no longer a command
134+
return [...base, spec.image, "gemini", "mcp", "list", "--debug"]
132135
}
133136

134137
const cleanupExistingContainers = (
@@ -328,14 +331,15 @@ export const runGeminiOauthLoginWithPrompt = (
328331
proc.exitCode.pipe(Effect.map(Number)),
329332
pipe(
330333
Deferred.await(authDeferred),
334+
Effect.delay("500 millis"),
331335
Effect.flatMap(() => proc.kill()),
332336
Effect.map(() => 0)
333337
)
334338
)
335339
)
336340

337-
yield* _(Fiber.join(stdoutFiber))
338-
yield* _(Fiber.join(stderrFiber))
341+
yield* _(Fiber.interrupt(stdoutFiber))
342+
yield* _(Fiber.interrupt(stderrFiber))
339343

340344
// Fix permissions for all files created by root in the volume
341345
yield* _(fixGeminiAuthPermissions(hostPath, spec.containerPath))

0 commit comments

Comments
 (0)