Skip to content

Commit 03c97dd

Browse files
authored
Add OpenCode + oh-my-opencode to dev containers (#43)
* feat(lib): preinstall opencode + oh-my-opencode * fix(lib): refactor opencode entrypoint template * feat(lib): auto-connect opencode and fix bun env * fix(lib): chown opencode auth after auto-connect * test(ci): add opencode autoconnect e2e * test(e2e): fix CI permissions for opencode autoconnect
1 parent 9cf8354 commit 03c97dd

File tree

8 files changed

+396
-9
lines changed

8 files changed

+396
-9
lines changed

.github/workflows/check.yml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,16 @@ jobs:
8484
run: pnpm --filter ./packages/app lint:effect
8585
- name: Lint Effect-TS (lib)
8686
run: pnpm --filter ./packages/lib lint:effect
87+
88+
e2e-opencode:
89+
name: E2E (OpenCode)
90+
runs-on: ubuntu-latest
91+
timeout-minutes: 25
92+
steps:
93+
- uses: actions/checkout@v6
94+
- name: Install dependencies
95+
uses: ./.github/actions/setup
96+
- name: Docker info
97+
run: docker version && docker compose version
98+
- name: OpenCode autoconnect
99+
run: bash scripts/e2e/opencode-autoconnect.sh

packages/lib/src/core/templates-entrypoint.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
} from "./templates-entrypoint/codex.js"
2020
import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates-entrypoint/git.js"
2121
import { renderEntrypointDockerGitBootstrap } from "./templates-entrypoint/nested-docker-git.js"
22+
import { renderEntrypointOpenCodeConfig } from "./templates-entrypoint/opencode.js"
2223
import { renderEntrypointBackgroundTasks } from "./templates-entrypoint/tasks.js"
2324
import {
2425
renderEntrypointBashCompletion,
@@ -33,6 +34,7 @@ export const renderEntrypoint = (config: TemplateConfig): string =>
3334
renderEntrypointAuthorizedKeys(config),
3435
renderEntrypointCodexHome(config),
3536
renderEntrypointCodexSharedAuth(config),
37+
renderEntrypointOpenCodeConfig(config),
3638
renderEntrypointDockerGitBootstrap(config),
3739
renderEntrypointMcpPlaywright(config),
3840
renderEntrypointZshShell(config),

packages/lib/src/core/templates-entrypoint/codex.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ $MANAGED_END
182182
EOF
183183
)"
184184
cat <<EOF > "$AGENTS_PATH"
185-
Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~
185+
Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, opencode, oh-my-opencode, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~
186186
$MANAGED_BLOCK
187187
Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции.
188188
EOF
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
import type { TemplateConfig } from "../domain.js"
2+
3+
const entrypointOpenCodeTemplate = `OPENCODE_DATA_DIR="/home/__SSH_USER__/.local/share/opencode"
4+
OPENCODE_AUTH_FILE="$OPENCODE_DATA_DIR/auth.json"
5+
OPENCODE_SHARED_HOME="__CODEX_HOME__-shared/opencode"
6+
OPENCODE_SHARED_AUTH_FILE="$OPENCODE_SHARED_HOME/auth.json"
7+
8+
# OpenCode: share auth.json across projects (so /connect is one-time)
9+
OPENCODE_SHARE_AUTH="\${OPENCODE_SHARE_AUTH:-1}"
10+
if [[ "$OPENCODE_SHARE_AUTH" == "1" ]]; then
11+
# Store in the shared auth volume to persist across projects/containers.
12+
mkdir -p "$OPENCODE_DATA_DIR" "$OPENCODE_SHARED_HOME"
13+
chown -R 1000:1000 "$OPENCODE_DATA_DIR" "$OPENCODE_SHARED_HOME" || true
14+
15+
# Guard against a bad bind mount creating a directory at auth.json.
16+
if [[ -d "$OPENCODE_AUTH_FILE" ]]; then
17+
mv "$OPENCODE_AUTH_FILE" "$OPENCODE_AUTH_FILE.bak-$(date +%s)" || true
18+
fi
19+
20+
# Migrate existing per-project auth into the shared location once.
21+
if [[ -f "$OPENCODE_AUTH_FILE" && ! -L "$OPENCODE_AUTH_FILE" ]]; then
22+
if [[ -f "$OPENCODE_SHARED_AUTH_FILE" ]]; then
23+
LOCAL_AUTH="$OPENCODE_AUTH_FILE" SHARED_AUTH="$OPENCODE_SHARED_AUTH_FILE" node - <<'NODE'
24+
const fs = require("fs")
25+
const localPath = process.env.LOCAL_AUTH
26+
const sharedPath = process.env.SHARED_AUTH
27+
const readJson = (p) => {
28+
try {
29+
return JSON.parse(fs.readFileSync(p, "utf8"))
30+
} catch {
31+
return {}
32+
}
33+
}
34+
const local = readJson(localPath)
35+
const shared = readJson(sharedPath)
36+
const merged = { ...local, ...shared } // shared wins on conflicts
37+
fs.writeFileSync(sharedPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
38+
NODE
39+
else
40+
cp "$OPENCODE_AUTH_FILE" "$OPENCODE_SHARED_AUTH_FILE" || true
41+
chmod 600 "$OPENCODE_SHARED_AUTH_FILE" || true
42+
fi
43+
chown 1000:1000 "$OPENCODE_SHARED_AUTH_FILE" || true
44+
rm -f "$OPENCODE_AUTH_FILE" || true
45+
fi
46+
47+
ln -sf "$OPENCODE_SHARED_AUTH_FILE" "$OPENCODE_AUTH_FILE"
48+
fi
49+
50+
# OpenCode: auto-seed auth from Codex (so /connect is automatic)
51+
OPENCODE_AUTO_CONNECT="\${OPENCODE_AUTO_CONNECT:-1}"
52+
if [[ "$OPENCODE_AUTO_CONNECT" == "1" ]]; then
53+
CODEX_AUTH_FILE="__CODEX_HOME__/auth.json"
54+
OPENCODE_SEED_AUTH="$OPENCODE_AUTH_FILE"
55+
if [[ "$OPENCODE_SHARE_AUTH" == "1" ]]; then
56+
OPENCODE_SEED_AUTH="$OPENCODE_SHARED_AUTH_FILE"
57+
fi
58+
CODEX_AUTH="$CODEX_AUTH_FILE" OPENCODE_AUTH="$OPENCODE_SEED_AUTH" node - <<'NODE'
59+
const fs = require("fs")
60+
const path = require("path")
61+
62+
const codexPath = process.env.CODEX_AUTH
63+
const opencodePath = process.env.OPENCODE_AUTH
64+
65+
if (!codexPath || !opencodePath) {
66+
process.exit(0)
67+
}
68+
69+
const readJson = (p) => {
70+
try {
71+
return JSON.parse(fs.readFileSync(p, "utf8"))
72+
} catch {
73+
return undefined
74+
}
75+
}
76+
77+
const writeJsonAtomic = (p, value) => {
78+
const dir = path.dirname(p)
79+
fs.mkdirSync(dir, { recursive: true })
80+
const tmp = path.join(dir, ".tmp-" + path.basename(p) + "-" + process.pid + "-" + Date.now())
81+
fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 })
82+
fs.renameSync(tmp, p)
83+
}
84+
85+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value)
86+
87+
const decodeJwtClaims = (jwt) => {
88+
if (typeof jwt !== "string") return undefined
89+
const parts = jwt.split(".")
90+
if (parts.length !== 3) return undefined
91+
try {
92+
const payload = Buffer.from(parts[1], "base64url").toString("utf8")
93+
return JSON.parse(payload)
94+
} catch {
95+
return undefined
96+
}
97+
}
98+
99+
const extractAccountIdFromClaims = (claims) => {
100+
if (!isRecord(claims)) return undefined
101+
if (typeof claims.chatgpt_account_id === "string") return claims.chatgpt_account_id
102+
const openaiAuth = claims["https://api.openai.com/auth"]
103+
if (isRecord(openaiAuth) && typeof openaiAuth.chatgpt_account_id === "string") {
104+
return openaiAuth.chatgpt_account_id
105+
}
106+
const orgs = claims.organizations
107+
if (Array.isArray(orgs) && orgs.length > 0) {
108+
const first = orgs[0]
109+
if (isRecord(first) && typeof first.id === "string") return first.id
110+
}
111+
return undefined
112+
}
113+
114+
const extractJwtExpiryMs = (claims) => {
115+
if (!isRecord(claims)) return undefined
116+
if (typeof claims.exp !== "number") return undefined
117+
return claims.exp * 1000
118+
}
119+
120+
const codex = readJson(codexPath)
121+
if (!isRecord(codex)) process.exit(0)
122+
123+
let opencode = readJson(opencodePath)
124+
if (!isRecord(opencode)) opencode = {}
125+
126+
if (opencode.openai) {
127+
process.exit(0)
128+
}
129+
130+
const apiKey = codex.OPENAI_API_KEY
131+
if (typeof apiKey === "string" && apiKey.trim().length > 0) {
132+
opencode.openai = { type: "api", key: apiKey.trim() }
133+
writeJsonAtomic(opencodePath, opencode)
134+
process.exit(0)
135+
}
136+
137+
const tokens = codex.tokens
138+
if (!isRecord(tokens)) process.exit(0)
139+
140+
const access = tokens.access_token
141+
const refresh = tokens.refresh_token
142+
if (typeof access !== "string" || access.length === 0) process.exit(0)
143+
if (typeof refresh !== "string" || refresh.length === 0) process.exit(0)
144+
145+
const accessClaims = decodeJwtClaims(access)
146+
const expires = extractJwtExpiryMs(accessClaims)
147+
if (typeof expires !== "number") process.exit(0)
148+
149+
let accountId = undefined
150+
if (typeof tokens.account_id === "string" && tokens.account_id.length > 0) {
151+
accountId = tokens.account_id
152+
} else {
153+
const idClaims = decodeJwtClaims(tokens.id_token)
154+
accountId =
155+
extractAccountIdFromClaims(idClaims) ||
156+
extractAccountIdFromClaims(accessClaims)
157+
}
158+
159+
const entry = {
160+
type: "oauth",
161+
refresh,
162+
access,
163+
expires,
164+
...(typeof accountId === "string" && accountId.length > 0 ? { accountId } : {})
165+
}
166+
167+
opencode.openai = entry
168+
writeJsonAtomic(opencodePath, opencode)
169+
NODE
170+
chown 1000:1000 "$OPENCODE_SEED_AUTH" 2>/dev/null || true
171+
fi
172+
173+
# OpenCode: ensure global config exists (plugins + permissions)
174+
OPENCODE_CONFIG_DIR="/home/__SSH_USER__/.config/opencode"
175+
OPENCODE_CONFIG_JSON="$OPENCODE_CONFIG_DIR/opencode.json"
176+
OPENCODE_CONFIG_JSONC="$OPENCODE_CONFIG_DIR/opencode.jsonc"
177+
178+
mkdir -p "$OPENCODE_CONFIG_DIR"
179+
chown -R 1000:1000 "$OPENCODE_CONFIG_DIR" || true
180+
181+
if [[ ! -f "$OPENCODE_CONFIG_JSON" && ! -f "$OPENCODE_CONFIG_JSONC" ]]; then
182+
cat <<'EOF' > "$OPENCODE_CONFIG_JSON"
183+
{
184+
"$schema": "https://opencode.ai/config.json",
185+
"plugin": ["oh-my-opencode"],
186+
"permission": {
187+
"doom_loop": "allow",
188+
"external_directory": "allow",
189+
"read": {
190+
"*": "allow",
191+
"*.env": "allow",
192+
"*.env.*": "allow",
193+
"*.env.example": "allow"
194+
}
195+
}
196+
}
197+
EOF
198+
chown 1000:1000 "$OPENCODE_CONFIG_JSON" || true
199+
fi`
200+
201+
// CHANGE: bootstrap OpenCode config (permissions + plugins) and share OpenCode auth.json across projects
202+
// WHY: make OpenCode usable out-of-the-box inside disposable docker-git containers
203+
// QUOTE(ТЗ): "Preinstall OpenCode and oh-my-opencode with full authorization of existing tools"
204+
// REF: issue-34
205+
// SOURCE: n/a
206+
// FORMAT THEOREM: forall s: start(s) -> config_exists(s)
207+
// PURITY: CORE
208+
// INVARIANT: never overwrites an existing opencode.json/opencode.jsonc
209+
// COMPLEXITY: O(1)
210+
export const renderEntrypointOpenCodeConfig = (config: TemplateConfig): string =>
211+
entrypointOpenCodeTemplate
212+
.replaceAll("__SSH_USER__", config.sshUser)
213+
.replaceAll("__CODEX_HOME__", config.codexHome)

packages/lib/src/core/templates-entrypoint/tasks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const renderEntrypointAutoUpdate = (): string =>
55
if [[ "$CODEX_AUTO_UPDATE" == "1" ]]; then
66
if command -v bun >/dev/null 2>&1; then
77
echo "[codex] updating via bun..."
8-
script -q -e -c "bun add -g @openai/codex@latest" /dev/null || true
8+
BUN_INSTALL=/usr/local/bun script -q -e -c "bun add -g @openai/codex@latest" /dev/null || true
99
else
1010
echo "[codex] bun not found, skipping auto-update"
1111
fi

packages/lib/src/core/templates/dockerfile.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,20 @@ RUN printf "export NVM_DIR=/usr/local/nvm\\n[ -s /usr/local/nvm/nvm.sh ] && . /u
3131
> /etc/profile.d/nvm.sh && chmod 0644 /etc/profile.d/nvm.sh`
3232

3333
const renderDockerfileBunPrelude = (config: TemplateConfig): string =>
34-
`# Tooling: pnpm + Codex CLI (bun)
34+
`# Tooling: pnpm + Codex CLI + oh-my-opencode (bun)
3535
RUN corepack enable && corepack prepare pnpm@${config.pnpmVersion} --activate
36-
ENV BUN_INSTALL=/usr/local/bun
3736
ENV TERM=xterm-256color
38-
ENV PATH="/usr/local/bun/bin:$PATH"
39-
RUN curl -fsSL https://bun.sh/install | bash
37+
RUN curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local/bun bash
4038
RUN ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun
41-
RUN script -q -e -c "bun add -g @openai/codex@latest" /dev/null
42-
RUN ln -sf /usr/local/bun/bin/codex /usr/local/bin/codex`
39+
RUN BUN_INSTALL=/usr/local/bun script -q -e -c "bun add -g @openai/codex@latest oh-my-opencode@latest" /dev/null
40+
RUN ln -sf /usr/local/bun/bin/codex /usr/local/bin/codex
41+
RUN ln -sf /usr/local/bun/bin/oh-my-opencode /usr/local/bin/oh-my-opencode`
42+
43+
const renderDockerfileOpenCode = (): string =>
44+
`# Tooling: OpenCode (binary)
45+
RUN curl -fsSL https://opencode.ai/install | HOME=/usr/local bash -s -- --no-modify-path
46+
RUN ln -sf /usr/local/.opencode/bin/opencode /usr/local/bin/opencode
47+
RUN opencode --version`
4348

4449
const dockerfilePlaywrightMcpBlock = String.raw`RUN npm install -g @playwright/mcp@latest
4550
@@ -85,7 +90,7 @@ EOF
8590
RUN chmod +x /usr/local/bin/docker-git-playwright-mcp`
8691

8792
const renderDockerfileBunProfile = (): string =>
88-
`RUN printf "export BUN_INSTALL=/usr/local/bun\\nexport PATH=/usr/local/bun/bin:$PATH\\n" \
93+
`RUN printf "export PATH=/usr/local/bun/bin:$PATH\\n" \
8994
> /etc/profile.d/bun.sh && chmod 0644 /etc/profile.d/bun.sh`
9095

9196
const renderDockerfileBun = (config: TemplateConfig): string =>
@@ -151,6 +156,7 @@ export const renderDockerfile = (config: TemplateConfig): string =>
151156
renderDockerfilePrompt(),
152157
renderDockerfileNode(),
153158
renderDockerfileBun(config),
159+
renderDockerfileOpenCode(),
154160
renderDockerfileUsers(config),
155161
renderDockerfileWorkspace(config)
156162
].join("\n\n")

packages/lib/tests/usecases/prepare-files.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ describe("prepareProjectFiles", () => {
107107
expect(dockerfile).toContain("docker-compose-v2")
108108
expect(entrypoint).toContain('DOCKER_GIT_HOME="/home/dev/.docker-git"')
109109
expect(entrypoint).toContain('SOURCE_SHARED_AUTH="/home/dev/.codex-shared/auth.json"')
110+
expect(entrypoint).toContain('OPENCODE_DATA_DIR="/home/dev/.local/share/opencode"')
111+
expect(entrypoint).toContain('OPENCODE_SHARED_HOME="/home/dev/.codex-shared/opencode"')
112+
expect(entrypoint).toContain('OPENCODE_CONFIG_DIR="/home/dev/.config/opencode"')
113+
expect(entrypoint).toContain('"plugin": ["oh-my-opencode"]')
110114
expect(composeBefore).toContain(":/home/dev/.docker-git")
111115
expect(composeBefore).not.toContain("dg-test-browser")
112116

0 commit comments

Comments
 (0)