Files
opencode-devbox/rootfs/usr/local/lib/opencode-devbox/generate-config.py
T
pi 37960186c6
Lint / docs-check (push) Successful in 5s
Lint / hadolint (push) Successful in 12s
Lint / actionlint (push) Has been cancelled
release: v2.9.0 — agent-browser, manifest reader, opencode 1.18.13
Not tagged yet; this is the v2.9.0 changeset landing on main.

Added
- agent-browser + a Playwright-managed headless Chromium in the base (~625 MB
  after deleting the redundant chromium_headless_shell build), so an agent can
  drive a real browser and VERIFY front-end work instead of assuming it renders.
  Ported from pi-devbox. AGENT_BROWSER_EXECUTABLE_PATH points at the stable
  symlink /usr/local/bin/agent-chrome, which the Dockerfile resolves with `find`
  rather than hardcoding: Playwright's browser dir is per-version AND per-arch
  (chrome-linux on arm64, chrome-linux64 on amd64), and the headless shell binary
  is named chrome-headless-shell so `-name chrome` skips it.
- opencode-devbox-version: a reader for the build manifest. The image has baked
  ground truth to /etc/opencode-devbox/build-manifest.json for several releases,
  but nothing read it and nothing printed it — so "which image am I running?"
  meant knowing the path by heart. Three modes (--json/--quiet/human), plus a
  live-vs-baked drift check, because NPM_CONFIG_PREFIX points at the persistent
  config volume and a user `npm install -g opencode` can shadow the baked binary.
  entrypoint-user.sh prints it as its first output.
- ENV COLORTERM=truecolor, completing a true-colour story the image already
  half-shipped (terminfo entries + Neovim termguicolors, but no capability
  advertisement, so bat/delta fell back to 256 colours).
- Smoke assertions for agent-browser, that agent-chrome resolves to an
  executable (catches a Playwright layout change, not just a dangling symlink),
  COLORTERM, the manifest's release_tag, and all three version-command modes.

Changed
- opencode 1.17.20 -> 1.18.13. Verified by diffing upstream source, not release
  notes: core config.ts, config/provider.ts and schema.json are byte-identical,
  so generate-config.py needs no change. 1.18.13 (published mid-audit) was
  re-verified separately — 249 files in the compare payload, under GitHub's
  300-file cap, so the list is complete rather than truncated; content is the
  Electron app plus localisation; the five contract-surface files hash identical
  at both tags. The bg-subagents removal trigger has NOT fired: runtime-flags.ts
  still gates the flag behind OPENCODE_EXPERIMENTAL at all three tags.
- yq: dropped Debian's apt package (the unrelated Python kislyuk/yq — jq syntax,
  3.x line) for mikefarah's Go yq v4 from GitHub. The cloud-init repo's
  provision.sh/deploy.sh need v4 syntax, and THIRD_PARTY.md already credited
  "yq (mikefarah)" while the image shipped the Python one, so this also closes a
  documented-vs-shipped mismatch. Smoke pins the contract to mikefarah v4.
  BEHAVIOUR CHANGE for any in-image script calling yq with jq-style syntax.
- mempalace pin 3.5.0 -> 3.6.0, in lockstep with pi-devbox (5724302). Reviewed
  for MCP tool-schema changes before bumping — none, and nothing touches
  diary_write.
- Default models -> claude-opus-5 (anthropic, and bedrock's
  global.anthropic.claude-opus-5) and openai/gpt-5.6. gpt-5.4 had gone stale:
  gpt-5.6 shipped four days before the v2.8.0 cut. Affects only new containers
  with no OPENCODE_MODEL and no existing config.
- Smoke size thresholds +650 MB (base 2950->3600, omos 3650->4300), sized to
  keep the same ~250 MB headroom so the guardrail still catches runaway growth
  rather than routine apt drift. Do NOT copy pi-devbox's number: it sums
  `docker history`, this repo uses `docker image inspect .Size`.

Documentation
- New README section "Choosing a provider and model", making explicit that the
  baked defaults are only defaults and nobody is locked to Anthropic/Bedrock,
  including the three real gotchas: defaults seed only a NEW config, an existing
  opencode.jsonc on the persistent volume is never rewritten, and switching
  model needs no rebuild.
- New README section "Browser automation (agent-browser)"; opencode-devbox-version
  documented under Build provenance; COLORTERM under Terminal compatibility.
- README Build Args table drift fixed — FOUR missing args added
  (AGENT_BROWSER_VERSION, PLAYWRIGHT_VERSION, YQ_VERSION and GITLEAKS_VERSION,
  the last of which had existed as an ARG but was never listed), plus rows for
  the two pinned args absent entirely (MEMPALACE_VERSION, DEBIAN_VERSION), plus
  a refreshed stale OPENCODE_VERSION example. Third consecutive release to find
  drift in this table.
- AGENTS.md: the stale MemPalace anyOf convention rewritten. It described a perl
  RUN block already DELETED at the 3.5.0 bump and asserted "PyPI latest is 3.4.0
  (== our pin), no release contains the fix yet, the workaround must stay" — all
  three false. Replaced with a pin-review rule. Two new conventions added: the
  agent-browser/Chromium size coupling, and the yq identity trap.
- THIRD_PARTY.md: agent-browser, Playwright, Chromium.

Verified locally with the CI-pinned hadolint 2.14.0 and actionlint 1.7.7, the
shell guard, DOCKER_HUB.md sync, bash -n, py_compile, and by generating the
config for all three providers.
2026-08-04 16:31:13 +02:00

317 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Generate opencode.json from environment variables on first container start.
Safety guarantees:
- NEVER overwrites an existing config (opencode.json / opencode.jsonc),
whether bind-mounted from the host, persisted in a named volume, or
previously generated. When a config already exists, this script instead
writes a NON-loaded `opencode.jsonc.proposed` sidecar (only when the
freshly-generated config would differ) so new image defaults can be
reviewed and merged by hand. opencode never loads the .proposed file.
- Requires OPENCODE_PROVIDER to be set. Without it, no file is written.
Environment variables:
OPENCODE_PROVIDER Required. One of: anthropic, openai, amazon-bedrock.
OPENCODE_MODEL Optional. Overrides the provider default model.
AWS_REGION Bedrock only. Default: us-east-1.
AWS_PROFILE Bedrock only. Default: default.
MEMPALACE_REMOTE_URL Optional. Point MemPalace at a shared HTTP endpoint
instead of spawning a local stdio server.
MEMPALACE_REMOTE_TOKEN Optional. Bearer token for MEMPALACE_REMOTE_URL.
MCP servers are auto-registered for tools detected on PATH:
- mempalace — LOCAL stdio if `mempalace-mcp` is installed, or EXTERNAL
(remote HTTP) when MEMPALACE_REMOTE_URL is set (shared palace)
- gitea-mcp (if installed) — registered but disabled by default
Output path: $HOME/.config/opencode/opencode.jsonc
(existing config preserved; newer defaults surfaced as
$HOME/.config/opencode/opencode.jsonc.proposed)
"""
from __future__ import annotations
import json
import os
import re
import shutil
import sys
from pathlib import Path
# Default model per provider. Update here when upstream ships a newer model.
#
# THESE ARE ONLY DEFAULTS — they apply when OPENCODE_MODEL is unset, and only
# for the provider selected by OPENCODE_PROVIDER. Any of them is overridden by
# setting OPENCODE_MODEL=<provider>/<model> in .env (see .env.example), and the
# generated opencode.jsonc is never overwritten on later starts, so a hand-edit
# of the config also survives. Users who don't use Anthropic or Bedrock should
# set OPENCODE_PROVIDER (and OPENCODE_MODEL if the per-provider default below
# isn't what they want) rather than editing this file — see the README section
# "Choosing a provider and model".
DEFAULT_MODELS: dict[str, str] = {
"anthropic": "anthropic/claude-opus-5",
"openai": "openai/gpt-5.6",
"amazon-bedrock": "amazon-bedrock/global.anthropic.claude-opus-5",
}
# Fallback when OPENCODE_PROVIDER is set but not recognized.
FALLBACK_MODEL = DEFAULT_MODELS["anthropic"]
SCHEMA_URL = "https://opencode.ai/config.json"
def build_config(provider: str, model: str) -> dict:
"""Build the base opencode.json structure for a provider."""
config: dict = {
"$schema": SCHEMA_URL,
"model": model,
"share": "disabled",
"autoupdate": False,
}
if provider == "amazon-bedrock":
config["provider"] = {
"amazon-bedrock": {
"options": {
"region": os.environ.get("AWS_REGION", "us-east-1"),
"profile": os.environ.get("AWS_PROFILE", "default"),
}
}
}
return config
def register_mcp_servers(config: dict) -> list[str]:
"""Auto-register MCP servers for tools detected on PATH.
Returns the list of server names that were added. The "mcp" key
is only added to the config when at least one server is registered.
"""
servers: dict[str, dict] = {}
# MemPalace — AI memory. Two modes, same env contract as the mempalace.ts
# pi extension (mempalace-toolkit), so one shared MemPalace can serve pi +
# opencode + native:
# LOCAL (default): stdio subprocess. `mempalace-mcp` is the entry-point
# binary shipped by the mempalace Python package. `uv tool install
# mempalace` places it on PATH as a shim whose shebang points at the
# isolated venv's Python, so system `python3 -m mempalace.mcp_server`
# (which would fail — system python3 can't import from the uv venv) is
# unnecessary here.
# EXTERNAL: set MEMPALACE_REMOTE_URL to a shared MemPalace HTTP endpoint
# (e.g. http://mempalace.lan:8765/mcp). MEMPALACE_REMOTE_TOKEN, if set,
# becomes a Bearer auth header. No local mempalace-mcp is spawned, so
# the binary need not even be installed in this mode.
remote_url = os.environ.get("MEMPALACE_REMOTE_URL", "").strip()
if remote_url:
entry: dict = {"type": "remote", "url": remote_url}
token = os.environ.get("MEMPALACE_REMOTE_TOKEN", "").strip()
if token:
entry["headers"] = {"Authorization": f"Bearer {token}"}
servers["mempalace"] = entry
elif shutil.which("mempalace-mcp"):
servers["mempalace"] = {
"type": "local",
"command": ["mempalace-mcp"],
}
# Gitea — self-hosted Git forge API (if installed).
# Disabled by default; user must set GITEA_ACCESS_TOKEN + GITEA_HOST
# and flip enabled=true in their config.
if shutil.which("gitea-mcp"):
servers["gitea"] = {
"type": "local",
"command": ["gitea-mcp", "-t", "stdio"],
"enabled": False,
}
# Context7 — up-to-date library documentation for LLMs (remote).
# Free tier works without an API key; set CONTEXT7_API_KEY for higher
# rate limits. No local binary needed — purely a remote MCP endpoint.
servers["context7"] = {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
}
if servers:
config["mcp"] = servers
return list(servers.keys())
def render_config(provider: str, model: str) -> tuple[dict, str, list[str]]:
"""Build the config dict and its JSONC rendering for a provider/model.
Shared by first-generation and the proposed-config side-channel so the
two can never drift. Returns (config_dict, jsonc_text, mcp_servers_added).
"""
config = build_config(provider, model)
added = register_mcp_servers(config)
# Write as JSONC so we can include helpful comments.
content = json.dumps(config, indent=2)
# Insert a comment about the Context7 API key after the context7 url line.
context7_comment = (
' "url": "https://mcp.context7.com/mcp"\n'
" // For higher rate limits, sign up at https://context7.com/dashboard\n"
' // and add: "headers": { "CONTEXT7_API_KEY": "{env:CONTEXT7_API_KEY}" }'
)
content = content.replace(
' "url": "https://mcp.context7.com/mcp"',
context7_comment,
)
return config, content, added
def _loads_jsonc(text: str) -> dict:
"""Parse JSONC (JSON + // line comments), preserving // inside strings.
Uses the same string-aware comment stripper as scripts/smoke-test.sh, so a
value such as an https:// URL is never corrupted. Raises on invalid JSON
(e.g. trailing commas) — callers treat that as 'cannot compare'.
"""
pattern = r'"(?:\\.|[^"\\])*"|//[^\n]*'
stripped = re.sub(
pattern,
lambda m: m.group(0) if m.group(0).startswith('"') else "",
text,
)
return json.loads(stripped)
PROPOSED_HEADER = """\
// ───────────────────────────────────────────────────────────────
// PROPOSED opencode config — NOT loaded by opencode.
//
// This is what opencode-devbox would generate for your CURRENT environment
// plus THIS image's defaults. It is written only when it differs from your
// live opencode.jsonc, as a manual-merge reference — e.g. a newer image added
// a default MCP server you do not have yet. opencode only loads
// opencode.json / opencode.jsonc, never this .proposed file.
//
// NOTE: this reflects env + image defaults, so a difference may be a new image
// default OR simply one of your own past edits (changed model, gitea
// enabled=true, …). Diff against your live config and merge what you want.
// Delete this file any time — it is rewritten on the next start if still
// relevant, and removed automatically once your live config matches.
// ───────────────────────────────────────────────────────────────
"""
def write_proposed(
proposed_file: Path, live_file: Path, config: dict, content: str
) -> None:
"""Non-destructively surface a newer default config beside the live one.
Writes <proposed_file> ONLY when the freshly-rendered config differs from
the live config (or the live config cannot be parsed for comparison).
Removes a stale proposed file when the live config already matches. NEVER
touches the live config itself.
"""
try:
live = _loads_jsonc(live_file.read_text())
differs = live != config
comparable = True
except (OSError, ValueError):
# Can't read or parse the live config — surface the proposal rather
# than silently guess they are equivalent.
comparable = False
differs = True
if comparable and not differs:
if proposed_file.exists():
try:
proposed_file.unlink()
print(
f"Live opencode config matches image defaults; removed "
f"stale {proposed_file.name}.",
file=sys.stderr,
)
except OSError:
pass
return
try:
proposed_file.write_text(PROPOSED_HEADER + content + "\n")
except OSError as e:
print(f"WARN: could not write {proposed_file}: {e}", file=sys.stderr)
return
why = "" if comparable else " (existing config could not be parsed for comparison)"
print(
f"A newer default opencode config is available at {proposed_file}{why}. "
"It is NOT applied automatically — diff/merge it into your live config "
"manually, or delete it to dismiss.",
file=sys.stderr,
)
def main() -> int:
provider = os.environ.get("OPENCODE_PROVIDER", "").strip()
if not provider:
# No provider set — nothing to do. Not an error.
return 0
home = Path(os.environ.get("HOME", "/home/developer"))
config_dir = home / ".config" / "opencode"
config_file = config_dir / "opencode.jsonc"
config_file_legacy = config_dir / "opencode.json"
proposed_file = config_dir / "opencode.jsonc.proposed"
if provider not in DEFAULT_MODELS:
print(
f"WARNING: unknown OPENCODE_PROVIDER={provider!r}, "
f"falling back to default model {FALLBACK_MODEL!r}.",
file=sys.stderr,
)
model = os.environ.get("OPENCODE_MODEL", "").strip() or DEFAULT_MODELS.get(
provider, FALLBACK_MODEL
)
config, content, added = render_config(provider, model)
# CRITICAL: never overwrite an existing config. Users may have bind-mounted
# their host config directory, or their config may be persisted in a named
# volume from a previous run. When a config already exists we instead
# surface any newer image defaults via a NON-loaded opencode.jsonc.proposed
# sidecar for manual merge (see write_proposed) — the live file is untouched.
existing = None
if config_file.exists():
existing = config_file
elif config_file_legacy.exists():
existing = config_file_legacy
if existing is not None:
print(
f"Existing config found at {existing} — not overwritten.",
file=sys.stderr,
)
write_proposed(proposed_file, existing, config, content)
return 0
print(f"Generating opencode config for provider: {provider}", file=sys.stderr)
config_dir.mkdir(parents=True, exist_ok=True)
with config_file.open("w") as f:
f.write(content)
f.write("\n")
# The fresh config now equals the image defaults — clear any stale proposal.
if proposed_file.exists():
try:
proposed_file.unlink()
except OSError:
pass
if added:
print(
f"MCP servers registered in opencode config: {', '.join(added)}.",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())