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
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@
!Dockerfile
!entrypoint.sh
!git-export.py
!convert-gemini.auth.ts
9 changes: 8 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ bun install -g "opencode-ai@${OPENCODE_VERSION}" || exit 1
&& popd
) || exit 1

###
# Gemini plugin
#
bun install -g 'opencode-gemini-auth@latest' || exit 1

###
# agent browser
(
Expand Down Expand Up @@ -200,7 +205,8 @@ RUN <<'FOE'
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
"engram"
"engram",
"file:///usr/local/bun/install/global/node_modules/opencode-gemini-auth"
],
"mcp": {
"engram": {
Expand Down Expand Up @@ -255,6 +261,7 @@ EOF
FOE

COPY --chmod=0555 entrypoint.sh /entrypoint.sh
COPY --chmod=0555 convert-gemini.auth.ts /usr/local/bin/convert-gemini.auth.ts

USER bun:bun

Expand Down
72 changes: 72 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
- [Building the Container Image](#building-the-container-image)
- [Build Arguments](#build-arguments)
- [Authentication Setup](#authentication-setup)
- [Reusing Existing Gemini CLI Authentication](#reusing-existing-gemini-cli-authentication)
- [Manual Gemini Authentication in the Container](#manual-gemini-authentication-in-the-container)
- [Environment File Option](#environment-file-option)
- [Important Environment Variables](#important-environment-variables)
- [Verifying Authentication](#verifying-authentication)
Expand Down Expand Up @@ -96,6 +98,76 @@ This gives the container access to:
- `/home/bun/.local/share/opencode` through the home mount for persisted local OpenCode and memory-related state
- `/work` for the project you want OpenCode to read and modify

### Reusing Existing Gemini CLI Authentication

If you already authenticated with `gemini-cli` on the host, the container can reuse that login
automatically.

At startup, `entrypoint.sh` checks whether `~/.gemini/oauth_creds.json` exists inside the
container. If it does, the Bun script `convert-gemini.auth.ts` converts that Gemini OAuth state
into OpenCode's auth store at `~/.local/share/opencode/auth.json`.

Typical run pattern:

```bash
docker run -it --rm \
-v $HOME:/home/bun \
-v ${PWD}:/work \
opencode-cli:dev
```

With that home-directory mount:

- host `~/.gemini/oauth_creds.json` becomes available in the container at `/home/bun/.gemini/oauth_creds.json`
- the entrypoint converts it into OpenCode auth automatically before launching `opencode`
- existing entries in `~/.local/share/opencode/auth.json` are preserved and only the `google` provider entry is updated

Notes:

- If `~/.gemini/oauth_creds.json` is not present, startup stays silent and OpenCode launches normally
- If the Gemini credentials file exists but is malformed or missing required token fields, container startup fails so the problem is visible
- The converter path inside the image is `/usr/local/bin/convert-gemini.auth.ts`

### Manual Gemini Authentication in the Container

If you do not already have reusable Gemini CLI credentials on the host, you can authenticate
manually from inside the container with the `opencode-gemini-auth` plugin.

Start the container with a bash shell instead of the normal entrypoint:

```bash
docker run -it --rm \
--entrypoint bash \
-v $HOME:/home/bun \
-v ${PWD}:/work \
opencode-cli:dev
```

Then run the login flow manually inside the container:

```bash
opencode auth login
```

In the OpenCode prompt flow:

- select `Google`
- select `OAuth with Google (Gemini CLI)`
- complete the browser-based authorization flow

If you are running the container in an environment where the browser callback cannot be completed
automatically, use the fallback flow described by the plugin and paste the redirected callback URL
or authorization code when prompted.

After successful login, the credential is stored in your mounted home directory under OpenCode's
data path, so future container runs can reuse it:

- `/home/bun/.local/share/opencode/auth.json` for provider auth
- `/home/bun/.config/opencode` for config

Once this has been done once, subsequent normal container starts can use the stored OpenCode auth
directly, without repeating the manual login flow.

### Environment File Option

If your OpenCode setup depends on provider-specific environment variables, keep them in a local
Expand Down
92 changes: 92 additions & 0 deletions convert-gemini.auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
#!/usr/bin/env bun
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"

type Gemini = {
access_token?: string
refresh_token?: string
expiry_date?: number | string
}

type OAuth = {
type: "oauth"
refresh: string
access: string
expires: number
}

type Auth = Record<string, OAuth | Record<string, unknown>>

function fail(msg: string): never {
console.error(`[convert-gemini.auth] ${msg}`)
process.exit(1)
}

function home() {
return process.env.HOME || os.homedir()
}

function data() {
return process.env.XDG_DATA_HOME || path.join(home(), ".local", "share")
}

function src() {
return process.env.GEMINI_OAUTH_CREDS_PATH || path.join(home(), ".gemini", "oauth_creds.json")
}

function dst() {
return process.env.OPENCODE_AUTH_PATH || path.join(data(), "opencode", "auth.json")
}

function ms(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) {
return value < 1e12 ? value * 1000 : value
}

if (typeof value === "string" && value.trim()) {
const num = Number(value)
if (Number.isFinite(num)) return num < 1e12 ? num * 1000 : num
const date = Date.parse(value)
if (!Number.isNaN(date)) return date
}

return Date.now() + 55 * 60 * 1000
}

async function json(file: string) {
return JSON.parse(await readFile(file, "utf8")) as Record<string, unknown>
}

async function existing(file: string): Promise<Auth> {
try {
return (await json(file)) as Auth
} catch {
return {}
}
}

async function main() {
const source = src()
const target = dst()
const creds = (await json(source)) as Gemini

if (!creds.refresh_token) fail(`missing refresh_token in ${source}`)
if (!creds.access_token) fail(`missing access_token in ${source}`)

const auth = await existing(target)
auth.google = {
type: "oauth",
refresh: creds.refresh_token,
access: creds.access_token,
expires: ms(creds.expiry_date),
}

await mkdir(path.dirname(target), { recursive: true })
await writeFile(target, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 })
await chmod(target, 0o600)
}

await main().catch((err) => {
fail(err instanceof Error ? err.message : String(err))
})
7 changes: 7 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,11 @@
# Ensure MISE configuration is loaded
source /etc/bash.bashrc

GEMINI_CREDS_PATH="${GEMINI_OAUTH_CREDS_PATH:-$HOME/.gemini/oauth_creds.json}"
CONVERTER_PATH="${CONVERTER_PATH:-/usr/local/bin/convert-gemini.auth.ts}"

if [[ -f "${GEMINI_CREDS_PATH}" ]]; then
bun "${CONVERTER_PATH}"
fi

exec /usr/local/bin/opencode "$@"
Loading