Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/app/src/docker-git/menu-auth-effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,10 @@ const resolveClaudeLogoutEffect = (labelOption: string | null) =>
authClaudeLogout({ _tag: "AuthClaudeLogout", label: labelOption, claudeAuthPath: claudeAuthRoot })

const resolveGeminiOauthEffect = (labelOption: string | null) =>
authGeminiLoginOauth({ _tag: "AuthGeminiLogin", label: labelOption, geminiAuthPath: geminiAuthRoot })
authGeminiLoginOauth({ _tag: "AuthGeminiLogin", label: labelOption, geminiAuthPath: geminiAuthRoot, isWeb: false })

const resolveGeminiApiKeyEffect = (labelOption: string | null, apiKey: string) =>
authGeminiLogin({ _tag: "AuthGeminiLogin", label: labelOption, geminiAuthPath: geminiAuthRoot }, apiKey)
authGeminiLogin({ _tag: "AuthGeminiLogin", label: labelOption, geminiAuthPath: geminiAuthRoot, isWeb: false }, apiKey)

const resolveGeminiLogoutEffect = (labelOption: string | null) =>
authGeminiLogout({ _tag: "AuthGeminiLogout", label: labelOption, geminiAuthPath: geminiAuthRoot })
Expand Down
4 changes: 1 addition & 3 deletions packages/app/src/docker-git/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,7 @@ const handleNonBaseCommand = (command: NonBaseCommand) =>
Match.when({ _tag: "SessionsList" }, (cmd) => listTerminalSessions(cmd))
)
.pipe(
Match.when({ _tag: "AuthGeminiLogin" }, (cmd) =>
cmd.isWeb ? authGeminiLoginOauth(cmd) : authGeminiLoginCli(cmd)
),
Match.when({ _tag: "AuthGeminiLogin" }, (cmd) => cmd.isWeb ? authGeminiLoginOauth(cmd) : authGeminiLoginCli(cmd)),
Match.when({ _tag: "AuthGeminiStatus" }, (cmd) => authGeminiStatus(cmd)),
Match.when({ _tag: "AuthGeminiLogout" }, (cmd) => authGeminiLogout(cmd)),
Match.when({ _tag: "SessionsKill" }, (cmd) => killTerminalProcess(cmd)),
Expand Down
24 changes: 12 additions & 12 deletions packages/lib/src/core/templates-entrypoint/agent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { Match } from "effect"
import type { TemplateConfig } from "../domain.js"

type AgentMode = "claude" | "codex" | "gemini"

const indentBlock = (block: string, size = 2): string => {
const prefix = " ".repeat(size)

Expand Down Expand Up @@ -46,20 +49,17 @@ if [[ -n "$AGENT_PROMPT" ]]; then
fi`
].join("\n\n")

const renderAgentPromptCommand = (mode: "claude" | "codex" | "gemini"): string => {
switch (mode) {
case "claude":
return String.raw`claude --dangerously-skip-permissions -p \"\$(cat \"$AGENT_PROMPT_FILE\")\"`
case "codex":
return String.raw`codex exec \"\$(cat \"$AGENT_PROMPT_FILE\")\"`
case "gemini":
return String.raw`gemini --approval-mode=yolo \"\$(cat \"$AGENT_PROMPT_FILE\")\"`
}
}
const renderAgentPromptCommand = (mode: AgentMode): string =>
Match.value(mode).pipe(
Match.when("claude", () => String.raw`claude --dangerously-skip-permissions -p \"\$(cat \"$AGENT_PROMPT_FILE\")\"`),
Match.when("codex", () => String.raw`codex exec \"\$(cat \"$AGENT_PROMPT_FILE\")\"`),
Match.when("gemini", () => String.raw`gemini --approval-mode=yolo \"\$(cat \"$AGENT_PROMPT_FILE\")\"`),
Match.exhaustive
)

const renderAgentAutoLaunchCommand = (
config: TemplateConfig,
mode: "claude" | "codex" | "gemini"
mode: AgentMode
): string =>
String
.raw`su - ${config.sshUser} -s /bin/bash -c "bash -lc '. /etc/profile 2>/dev/null || true; . \"$AGENT_ENV_FILE\" 2>/dev/null || true; cd \"$TARGET_DIR\" && ${
Expand All @@ -68,7 +68,7 @@ const renderAgentAutoLaunchCommand = (

const renderAgentModeBlock = (
config: TemplateConfig,
mode: "claude" | "codex" | "gemini"
mode: AgentMode
): string => {
const startMessage = `[agent] starting ${mode}...`
const interactiveMessage = `[agent] ${mode} started in interactive mode (use SSH to connect)`
Expand Down
3 changes: 2 additions & 1 deletion packages/lib/src/core/templates-entrypoint/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import type { TemplateConfig } from "../domain.js"

const geminiAuthRootContainerPath = (sshUser: string): string => `/home/${sshUser}/.docker-git/.orch/auth/gemini`

const geminiAuthConfigTemplate = String.raw`# Gemini CLI: expose GEMINI_HOME for sessions (OAuth cache lives under ~/.docker-git/.orch/auth/gemini)
const geminiAuthConfigTemplate = String
.raw`# Gemini CLI: expose GEMINI_HOME for sessions (OAuth cache lives under ~/.docker-git/.orch/auth/gemini)
GEMINI_LABEL_RAW="$GEMINI_AUTH_LABEL"
if [[ -z "$GEMINI_LABEL_RAW" ]]; then
GEMINI_LABEL_RAW="default"
Expand Down
95 changes: 45 additions & 50 deletions packages/lib/src/usecases/auth-gemini-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import type * as Scope from "effect/Scope"
import * as Stream from "effect/Stream"

import { stripAnsi, writeChunkToFd } from "../shell/ansi-strip.js"
import { runCommandCapture, runCommandExitCode } from "../shell/command-runner.js"
import { resolveDockerVolumeHostPath } from "../shell/docker-auth.js"
import { AuthError, CommandFailedError } from "../shell/errors.js"
import { runCommandCapture, runCommandExitCode } from "../shell/command-runner.js"

// CHANGE: add Gemini CLI OAuth authentication flow
// WHY: enable Gemini CLI OAuth login in headless/Docker environments
Expand Down Expand Up @@ -48,31 +48,23 @@ const detectAuthResult = (output: string): GeminiAuthResult => {
const normalized = stripAnsi(output).toLowerCase()

// Markers that indicate we are in the middle of or after an auth flow
const authInitiated =
normalized.includes("please visit the following url") ||
normalized.includes("enter the authorization code") ||
normalized.includes("authorized the application")

for (const pattern of authSuccessPatterns) {
if (normalized.includes(pattern.toLowerCase())) {
// If we saw auth initiation, any success pattern is a real success
if (authInitiated) {
return "success"
}
// If we didn't see initiation but see success, it might be the banner
// BUT if it's "Logged in with Google" and we're NOT in initiation,
// it means we're ALREADY logged in, so we can also stop.
if (normalized.includes("logged in with google")) {
return "success"
}
}
}
const authInitiated = [
"please visit the following url",
"enter the authorization code",
"authorized the application"
].some((m) => normalized.includes(m))

const isSuccess = authSuccessPatterns.some(
(pattern) =>
normalized.includes(pattern.toLowerCase()) &&
(authInitiated || normalized.includes("logged in with google"))
)

for (const pattern of authFailurePatterns) {
if (normalized.includes(pattern.toLowerCase())) {
return "failure"
}
}
if (isSuccess) return "success"

const isFailure = authFailurePatterns.some((pattern) => normalized.includes(pattern.toLowerCase()))

if (isFailure) return "failure"

return "pending"
}
Expand Down Expand Up @@ -166,7 +158,7 @@ const cleanupExistingContainers = (
cwd: process.cwd(),
command: "docker",
args: ["rm", "-f", ...ids]
}).pipe(Effect.catchAll(() => Effect.succeed(0)))
}).pipe(Effect.orElse(() => Effect.succeed(0)))
)
}
})
Expand All @@ -189,7 +181,7 @@ const pumpDockerOutput = (
source: Stream.Stream<Uint8Array, PlatformError>,
fd: number,
resultBox: { value: GeminiAuthResult },
authDeferred: Deferred.Deferred<void, never>
authDeferred: Deferred.Deferred<undefined>
): Effect.Effect<void, PlatformError> => {
const decoder = new TextDecoder("utf-8")
let outputWindow = ""
Expand All @@ -198,7 +190,9 @@ const pumpDockerOutput = (
source,
Stream.runForEach((chunk) =>
Effect.gen(function*(_) {
yield* _(Effect.sync(() => writeChunkToFd(fd, chunk)))
yield* _(Effect.sync(() => {
writeChunkToFd(fd, chunk)
}))
outputWindow += decoder.decode(chunk)
if (outputWindow.length > outputWindowSize) {
outputWindow = outputWindow.slice(-outputWindowSize)
Expand Down Expand Up @@ -276,6 +270,26 @@ const printOauthInstructions = (): Effect.Effect<void> =>

// CHANGE: run Gemini CLI OAuth login with interactive prompt and port forwarding
// WHY: Gemini CLI OAuth callback now works in Docker via fixed port forwarding
const fixGeminiAuthPermissions = (hostPath: string, containerPath: string) =>
runCommandExitCode({
cwd: process.cwd(),
command: "docker",
args: [
"run",
"--rm",
"-v",
`${hostPath}:${containerPath}`,
"alpine",
"chmod",
"-R",
"777",
containerPath
]
}).pipe(
Effect.tapError((err) => Effect.logWarning(`Failed to fix Gemini auth permissions: ${String(err)}`)),
Effect.orElse(() => Effect.succeed(0))
)

// QUOTE(ТЗ): "Типо ждал пока мы вставим ссылку"
// REF: issue-146, PR-147 comment
// SOURCE: https://github.com/google-gemini/gemini-cli
Expand Down Expand Up @@ -304,7 +318,7 @@ export const runGeminiOauthLoginWithPrompt = (
const spec = buildDockerGeminiAuthSpec(cwd, hostPath, options.image, options.containerPath, port)
const proc = yield* _(startDockerProcess(executor, spec))

const authDeferred = yield* _(Deferred.make<void, never>())
const authDeferred = yield* _(Deferred.make<undefined>())
const resultBox: { value: GeminiAuthResult } = { value: "pending" }
const stdoutFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stdout, 1, resultBox, authDeferred)))
const stderrFiber = yield* _(Effect.forkScoped(pumpDockerOutput(proc.stderr, 2, resultBox, authDeferred)))
Expand All @@ -318,32 +332,13 @@ export const runGeminiOauthLoginWithPrompt = (
Effect.map(() => 0)
)
)
) as Effect.Effect<number, PlatformError>
)

yield* _(Fiber.join(stdoutFiber))
yield* _(Fiber.join(stderrFiber))

// Fix permissions for all files created by root in the volume
yield* _(
runCommandExitCode({
cwd: process.cwd(),
command: "docker",
args: [
"run",
"--rm",
"-v",
`${hostPath}:${spec.containerPath}`,
"alpine",
"chmod",
"-R",
"777",
spec.containerPath
]
}).pipe(
Effect.tapError((err) => Effect.logWarning(`Failed to fix Gemini auth permissions: ${err}`)),
Effect.catchAll(() => Effect.succeed(0))
)
)
yield* _(fixGeminiAuthPermissions(hostPath, spec.containerPath))

return yield* _(resolveGeminiLoginResult(resultBox.value, exitCode))
})
Expand Down
110 changes: 54 additions & 56 deletions packages/lib/src/usecases/auth-gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,47 @@ export const authGeminiLoginCli = (
// QUOTE(ТЗ): "Мне надо что бы он её умел принимать, типо ждал пока мы вставим ссылку"
// REF: issue-146, PR-147 comment from skulidropek
// SOURCE: https://github.com/google-gemini/gemini-cli
const prepareGeminiCredentialsDir = (
cwd: string,
accountPath: string,
fs: FileSystem.FileSystem
) =>
Effect.gen(function*(_) {
const credentialsDir = geminiCredentialsPath(accountPath)
const removeFallback = pipe(
runCommandExitCode({
cwd,
command: "docker",
args: ["run", "--rm", "-v", `${accountPath}:/target`, "alpine", "rm", "-rf", "/target/.gemini"]
}),
Effect.asVoid,
Effect.orElse(() => Effect.void)
)

yield* _(
fs.remove(credentialsDir, { recursive: true, force: true }).pipe(
Effect.orElse(() => removeFallback)
)
)
yield* _(fs.makeDirectory(credentialsDir, { recursive: true }))
return credentialsDir
})

const writeInitialSettings = (credentialsDir: string, fs: FileSystem.FileSystem) =>
Effect.gen(function*(_) {
const settingsPath = `${credentialsDir}/settings.json`
yield* _(fs.writeFileString(settingsPath, JSON.stringify({ security: { folderTrust: { enabled: false } } })))

const trustedFoldersPath = `${credentialsDir}/trustedFolders.json`
yield* _(
fs.writeFileString(
trustedFoldersPath,
JSON.stringify({ "/": "TRUST_FOLDER", [geminiContainerHomeDir]: "TRUST_FOLDER" })
)
)
return settingsPath
})

// FORMAT THEOREM: forall cmd: authGeminiLoginOauth(cmd) -> oauth_credentials_stored | error
// PURITY: SHELL
// EFFECT: Effect<void, AuthError | PlatformError | CommandFailedError, GeminiRuntime>
Expand All @@ -283,51 +324,8 @@ export const authGeminiLoginOauth = (
command,
({ accountPath, cwd, fs }) =>
Effect.gen(function*(_) {
// Ensure .gemini directory exists and is empty for fresh OAuth credentials
const credentialsDir = geminiCredentialsPath(accountPath)
yield* _(
fs.remove(credentialsDir, { recursive: true, force: true }).pipe(
Effect.catchAll(() =>
pipe(
runCommandExitCode({
cwd,
command: "docker",
args: ["run", "--rm", "-v", `${accountPath}:/target`, "alpine", "rm", "-rf", "/target/.gemini"]
}),
Effect.asVoid,
Effect.catchAll(() => Effect.void)
)
)
) as Effect.Effect<void, never, CommandExecutor.CommandExecutor>
)
yield* _(fs.makeDirectory(credentialsDir, { recursive: true }))

// Pre-create settings.json to disable folder trust prompt
const settingsPath = `${credentialsDir}/settings.json`
yield* _(
fs.writeFileString(
settingsPath,
JSON.stringify({
security: {
folderTrust: {
enabled: false
}
}
})
)
)

// Pre-trust the container's home directory to skip interactive prompt
const trustedFoldersPath = `${credentialsDir}/trustedFolders.json`
yield* _(
fs.writeFileString(
trustedFoldersPath,
JSON.stringify({
"/": "TRUST_FOLDER",
[geminiContainerHomeDir]: "TRUST_FOLDER"
})
)
)
const credentialsDir = yield* _(prepareGeminiCredentialsDir(cwd, accountPath, fs))
const settingsPath = yield* _(writeInitialSettings(credentialsDir, fs))

yield* _(
runGeminiOauthLoginWithPrompt(cwd, accountPath, {
Expand All @@ -340,17 +338,17 @@ export const authGeminiLoginOauth = (
yield* _(
fs.writeFileString(
settingsPath,
JSON.stringify({
security: {
folderTrust: {
enabled: false
},
auth: {
selectedType: "oauth-personal"
},
approvalPolicy: "never"
}
}, null, 2) + "\n"
JSON.stringify(
{
security: {
folderTrust: { enabled: false },
auth: { selectedType: "oauth-personal" },
approvalPolicy: "never"
}
},
null,
2
) + "\n"
)
)
}),
Expand Down
Loading