diff --git a/.dockerignore b/.dockerignore index ca7a3a1..35d8188 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,4 @@ !Dockerfile !entrypoint.sh !git-export.py +!convert-gemini.auth.ts diff --git a/Dockerfile b/Dockerfile index df52273..574b557 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 ( @@ -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": { @@ -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 diff --git a/README.md b/README.md index e3206f3..62b8118 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/convert-gemini.auth.ts b/convert-gemini.auth.ts new file mode 100644 index 0000000..a744810 --- /dev/null +++ b/convert-gemini.auth.ts @@ -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> + +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 +} + +async function existing(file: string): Promise { + 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)) +}) diff --git a/entrypoint.sh b/entrypoint.sh index 13a129b..c965716 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -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 "$@"