37960186c6
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.
317 lines
12 KiB
Python
Executable File
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())
|