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
13 changes: 13 additions & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,16 @@ jobs:
run: pnpm --filter ./packages/app lint:effect
- name: Lint Effect-TS (lib)
run: pnpm --filter ./packages/lib lint:effect

e2e-opencode:
name: E2E (OpenCode)
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v6
- name: Install dependencies
uses: ./.github/actions/setup
- name: Docker info
run: docker version && docker compose version
- name: OpenCode autoconnect
run: bash scripts/e2e/opencode-autoconnect.sh
2 changes: 2 additions & 0 deletions packages/lib/src/core/templates-entrypoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
} from "./templates-entrypoint/codex.js"
import { renderEntrypointGitConfig, renderEntrypointGitHooks } from "./templates-entrypoint/git.js"
import { renderEntrypointDockerGitBootstrap } from "./templates-entrypoint/nested-docker-git.js"
import { renderEntrypointOpenCodeConfig } from "./templates-entrypoint/opencode.js"
import { renderEntrypointBackgroundTasks } from "./templates-entrypoint/tasks.js"
import {
renderEntrypointBashCompletion,
Expand All @@ -33,6 +34,7 @@ export const renderEntrypoint = (config: TemplateConfig): string =>
renderEntrypointAuthorizedKeys(config),
renderEntrypointCodexHome(config),
renderEntrypointCodexSharedAuth(config),
renderEntrypointOpenCodeConfig(config),
renderEntrypointDockerGitBootstrap(config),
renderEntrypointMcpPlaywright(config),
renderEntrypointZshShell(config),
Expand Down
2 changes: 1 addition & 1 deletion packages/lib/src/core/templates-entrypoint/codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ $MANAGED_END
EOF
)"
cat <<EOF > "$AGENTS_PATH"
Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~
Ты автономный агент, который имеет полностью все права управления контейнером. У тебя есть доступ к командам sudo, gh, codex, opencode, oh-my-opencode, git, node, pnpm и всем остальным другим. Проекты с которыми идёт работа лежат по пути ~
$MANAGED_BLOCK
Если ты видишь файлы AGENTS.md внутри проекта, ты обязан их читать и соблюдать инструкции.
EOF
Expand Down
213 changes: 213 additions & 0 deletions packages/lib/src/core/templates-entrypoint/opencode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import type { TemplateConfig } from "../domain.js"

const entrypointOpenCodeTemplate = `OPENCODE_DATA_DIR="/home/__SSH_USER__/.local/share/opencode"
OPENCODE_AUTH_FILE="$OPENCODE_DATA_DIR/auth.json"
OPENCODE_SHARED_HOME="__CODEX_HOME__-shared/opencode"
OPENCODE_SHARED_AUTH_FILE="$OPENCODE_SHARED_HOME/auth.json"

# OpenCode: share auth.json across projects (so /connect is one-time)
OPENCODE_SHARE_AUTH="\${OPENCODE_SHARE_AUTH:-1}"
if [[ "$OPENCODE_SHARE_AUTH" == "1" ]]; then
# Store in the shared auth volume to persist across projects/containers.
mkdir -p "$OPENCODE_DATA_DIR" "$OPENCODE_SHARED_HOME"
chown -R 1000:1000 "$OPENCODE_DATA_DIR" "$OPENCODE_SHARED_HOME" || true

# Guard against a bad bind mount creating a directory at auth.json.
if [[ -d "$OPENCODE_AUTH_FILE" ]]; then
mv "$OPENCODE_AUTH_FILE" "$OPENCODE_AUTH_FILE.bak-$(date +%s)" || true
fi

# Migrate existing per-project auth into the shared location once.
if [[ -f "$OPENCODE_AUTH_FILE" && ! -L "$OPENCODE_AUTH_FILE" ]]; then
if [[ -f "$OPENCODE_SHARED_AUTH_FILE" ]]; then
LOCAL_AUTH="$OPENCODE_AUTH_FILE" SHARED_AUTH="$OPENCODE_SHARED_AUTH_FILE" node - <<'NODE'
const fs = require("fs")
const localPath = process.env.LOCAL_AUTH
const sharedPath = process.env.SHARED_AUTH
const readJson = (p) => {
try {
return JSON.parse(fs.readFileSync(p, "utf8"))
} catch {
return {}
}
}
const local = readJson(localPath)
const shared = readJson(sharedPath)
const merged = { ...local, ...shared } // shared wins on conflicts
fs.writeFileSync(sharedPath, JSON.stringify(merged, null, 2), { mode: 0o600 })
NODE
else
cp "$OPENCODE_AUTH_FILE" "$OPENCODE_SHARED_AUTH_FILE" || true
chmod 600 "$OPENCODE_SHARED_AUTH_FILE" || true
fi
chown 1000:1000 "$OPENCODE_SHARED_AUTH_FILE" || true
rm -f "$OPENCODE_AUTH_FILE" || true
fi

ln -sf "$OPENCODE_SHARED_AUTH_FILE" "$OPENCODE_AUTH_FILE"
fi

# OpenCode: auto-seed auth from Codex (so /connect is automatic)
OPENCODE_AUTO_CONNECT="\${OPENCODE_AUTO_CONNECT:-1}"
if [[ "$OPENCODE_AUTO_CONNECT" == "1" ]]; then
CODEX_AUTH_FILE="__CODEX_HOME__/auth.json"
OPENCODE_SEED_AUTH="$OPENCODE_AUTH_FILE"
if [[ "$OPENCODE_SHARE_AUTH" == "1" ]]; then
OPENCODE_SEED_AUTH="$OPENCODE_SHARED_AUTH_FILE"
fi
CODEX_AUTH="$CODEX_AUTH_FILE" OPENCODE_AUTH="$OPENCODE_SEED_AUTH" node - <<'NODE'
const fs = require("fs")
const path = require("path")

const codexPath = process.env.CODEX_AUTH
const opencodePath = process.env.OPENCODE_AUTH

if (!codexPath || !opencodePath) {
process.exit(0)
}

const readJson = (p) => {
try {
return JSON.parse(fs.readFileSync(p, "utf8"))
} catch {
return undefined
}
}

const writeJsonAtomic = (p, value) => {
const dir = path.dirname(p)
fs.mkdirSync(dir, { recursive: true })
const tmp = path.join(dir, ".tmp-" + path.basename(p) + "-" + process.pid + "-" + Date.now())
fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 })
fs.renameSync(tmp, p)
}

const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value)

const decodeJwtClaims = (jwt) => {
if (typeof jwt !== "string") return undefined
const parts = jwt.split(".")
if (parts.length !== 3) return undefined
try {
const payload = Buffer.from(parts[1], "base64url").toString("utf8")
return JSON.parse(payload)
} catch {
return undefined
}
}

const extractAccountIdFromClaims = (claims) => {
if (!isRecord(claims)) return undefined
if (typeof claims.chatgpt_account_id === "string") return claims.chatgpt_account_id
const openaiAuth = claims["https://api.openai.com/auth"]
if (isRecord(openaiAuth) && typeof openaiAuth.chatgpt_account_id === "string") {
return openaiAuth.chatgpt_account_id
}
const orgs = claims.organizations
if (Array.isArray(orgs) && orgs.length > 0) {
const first = orgs[0]
if (isRecord(first) && typeof first.id === "string") return first.id
}
return undefined
}

const extractJwtExpiryMs = (claims) => {
if (!isRecord(claims)) return undefined
if (typeof claims.exp !== "number") return undefined
return claims.exp * 1000
}

const codex = readJson(codexPath)
if (!isRecord(codex)) process.exit(0)

let opencode = readJson(opencodePath)
if (!isRecord(opencode)) opencode = {}

if (opencode.openai) {
process.exit(0)
}

const apiKey = codex.OPENAI_API_KEY
if (typeof apiKey === "string" && apiKey.trim().length > 0) {
opencode.openai = { type: "api", key: apiKey.trim() }
writeJsonAtomic(opencodePath, opencode)
process.exit(0)
}

const tokens = codex.tokens
if (!isRecord(tokens)) process.exit(0)

const access = tokens.access_token
const refresh = tokens.refresh_token
if (typeof access !== "string" || access.length === 0) process.exit(0)
if (typeof refresh !== "string" || refresh.length === 0) process.exit(0)

const accessClaims = decodeJwtClaims(access)
const expires = extractJwtExpiryMs(accessClaims)
if (typeof expires !== "number") process.exit(0)

let accountId = undefined
if (typeof tokens.account_id === "string" && tokens.account_id.length > 0) {
accountId = tokens.account_id
} else {
const idClaims = decodeJwtClaims(tokens.id_token)
accountId =
extractAccountIdFromClaims(idClaims) ||
extractAccountIdFromClaims(accessClaims)
}

const entry = {
type: "oauth",
refresh,
access,
expires,
...(typeof accountId === "string" && accountId.length > 0 ? { accountId } : {})
}

opencode.openai = entry
writeJsonAtomic(opencodePath, opencode)
NODE
chown 1000:1000 "$OPENCODE_SEED_AUTH" 2>/dev/null || true
fi

# OpenCode: ensure global config exists (plugins + permissions)
OPENCODE_CONFIG_DIR="/home/__SSH_USER__/.config/opencode"
OPENCODE_CONFIG_JSON="$OPENCODE_CONFIG_DIR/opencode.json"
OPENCODE_CONFIG_JSONC="$OPENCODE_CONFIG_DIR/opencode.jsonc"

mkdir -p "$OPENCODE_CONFIG_DIR"
chown -R 1000:1000 "$OPENCODE_CONFIG_DIR" || true

if [[ ! -f "$OPENCODE_CONFIG_JSON" && ! -f "$OPENCODE_CONFIG_JSONC" ]]; then
cat <<'EOF' > "$OPENCODE_CONFIG_JSON"
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["oh-my-opencode"],
"permission": {
"doom_loop": "allow",
"external_directory": "allow",
"read": {
"*": "allow",
"*.env": "allow",
"*.env.*": "allow",
"*.env.example": "allow"
}
}
}
EOF
chown 1000:1000 "$OPENCODE_CONFIG_JSON" || true
fi`

// CHANGE: bootstrap OpenCode config (permissions + plugins) and share OpenCode auth.json across projects
// WHY: make OpenCode usable out-of-the-box inside disposable docker-git containers
// QUOTE(ТЗ): "Preinstall OpenCode and oh-my-opencode with full authorization of existing tools"
// REF: issue-34
// SOURCE: n/a
// FORMAT THEOREM: forall s: start(s) -> config_exists(s)
// PURITY: CORE
// INVARIANT: never overwrites an existing opencode.json/opencode.jsonc
// COMPLEXITY: O(1)
export const renderEntrypointOpenCodeConfig = (config: TemplateConfig): string =>
entrypointOpenCodeTemplate
.replaceAll("__SSH_USER__", config.sshUser)
.replaceAll("__CODEX_HOME__", config.codexHome)
2 changes: 1 addition & 1 deletion packages/lib/src/core/templates-entrypoint/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const renderEntrypointAutoUpdate = (): string =>
if [[ "$CODEX_AUTO_UPDATE" == "1" ]]; then
if command -v bun >/dev/null 2>&1; then
echo "[codex] updating via bun..."
script -q -e -c "bun add -g @openai/codex@latest" /dev/null || true
BUN_INSTALL=/usr/local/bun script -q -e -c "bun add -g @openai/codex@latest" /dev/null || true
else
echo "[codex] bun not found, skipping auto-update"
fi
Expand Down
20 changes: 13 additions & 7 deletions packages/lib/src/core/templates/dockerfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,20 @@ RUN printf "export NVM_DIR=/usr/local/nvm\\n[ -s /usr/local/nvm/nvm.sh ] && . /u
> /etc/profile.d/nvm.sh && chmod 0644 /etc/profile.d/nvm.sh`

const renderDockerfileBunPrelude = (config: TemplateConfig): string =>
`# Tooling: pnpm + Codex CLI (bun)
`# Tooling: pnpm + Codex CLI + oh-my-opencode (bun)
RUN corepack enable && corepack prepare pnpm@${config.pnpmVersion} --activate
ENV BUN_INSTALL=/usr/local/bun
ENV TERM=xterm-256color
ENV PATH="/usr/local/bun/bin:$PATH"
RUN curl -fsSL https://bun.sh/install | bash
RUN curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local/bun bash
RUN ln -sf /usr/local/bun/bin/bun /usr/local/bin/bun
RUN script -q -e -c "bun add -g @openai/codex@latest" /dev/null
RUN ln -sf /usr/local/bun/bin/codex /usr/local/bin/codex`
RUN BUN_INSTALL=/usr/local/bun script -q -e -c "bun add -g @openai/codex@latest oh-my-opencode@latest" /dev/null
RUN ln -sf /usr/local/bun/bin/codex /usr/local/bin/codex
RUN ln -sf /usr/local/bun/bin/oh-my-opencode /usr/local/bin/oh-my-opencode`

const renderDockerfileOpenCode = (): string =>
`# Tooling: OpenCode (binary)
RUN curl -fsSL https://opencode.ai/install | HOME=/usr/local bash -s -- --no-modify-path
RUN ln -sf /usr/local/.opencode/bin/opencode /usr/local/bin/opencode
RUN opencode --version`

const dockerfilePlaywrightMcpBlock = String.raw`RUN npm install -g @playwright/mcp@latest

Expand Down Expand Up @@ -85,7 +90,7 @@ EOF
RUN chmod +x /usr/local/bin/docker-git-playwright-mcp`

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

const renderDockerfileBun = (config: TemplateConfig): string =>
Expand Down Expand Up @@ -151,6 +156,7 @@ export const renderDockerfile = (config: TemplateConfig): string =>
renderDockerfilePrompt(),
renderDockerfileNode(),
renderDockerfileBun(config),
renderDockerfileOpenCode(),
renderDockerfileUsers(config),
renderDockerfileWorkspace(config)
].join("\n\n")
4 changes: 4 additions & 0 deletions packages/lib/tests/usecases/prepare-files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ describe("prepareProjectFiles", () => {
expect(dockerfile).toContain("docker-compose-v2")
expect(entrypoint).toContain('DOCKER_GIT_HOME="/home/dev/.docker-git"')
expect(entrypoint).toContain('SOURCE_SHARED_AUTH="/home/dev/.codex-shared/auth.json"')
expect(entrypoint).toContain('OPENCODE_DATA_DIR="/home/dev/.local/share/opencode"')
expect(entrypoint).toContain('OPENCODE_SHARED_HOME="/home/dev/.codex-shared/opencode"')
expect(entrypoint).toContain('OPENCODE_CONFIG_DIR="/home/dev/.config/opencode"')
expect(entrypoint).toContain('"plugin": ["oh-my-opencode"]')
expect(composeBefore).toContain(":/home/dev/.docker-git")
expect(composeBefore).not.toContain("dg-test-browser")

Expand Down
Loading