From 231f67f21e0627025499bf2e958301e7ec5e84cb Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Wed, 5 Aug 2026 22:05:59 -0700 Subject: [PATCH 1/2] feat(build): versions-up pins what it built and PRs the bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build cloak in versions-up; resolve cloakbrowser wrapper from npm - write versions.env from the exact versions built, not a re-fetch — a version published mid-build can no longer land in pins untested - new scripts/versions-pr.sh: commit in a temp worktree detached at origin/main, force-push chore/version-pins-refresh, gh pr create; user checkout stays untouched; PR=0 opts out - PR-stage failure warns and exits 0; pins stay written locally - write_version_pins moved to shared version-pins.sh - tests: cloak build args, pin write, outage no-write, PR soft-fail; hermetic versions-pr suite (real git against bare origin, fake gh) Close #538 Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 5 ++ DEV-LOGS.md | 5 ++ Makefile | 10 ++- scripts/update-version-pins.sh | 34 -------- scripts/version-pins.sh | 36 ++++++++ scripts/version-upgrade.sh | 89 +++++++++++++++++-- scripts/versions-pr.sh | 151 +++++++++++++++++++++++++++++++++ tests/version-upgrade.sh | 114 +++++++++++++++++++++++++ tests/versions-pr.sh | 133 +++++++++++++++++++++++++++++ 9 files changed, 535 insertions(+), 42 deletions(-) create mode 100755 scripts/versions-pr.sh create mode 100755 tests/versions-pr.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 18235bf..0558a4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Changed +- `make versions-up` is now the whole pin flow: builds core, main, + rust, and cloak at latest upstream, writes versions.env from the + exact versions built (no post-build re-fetch race), and opens the + pin PR on chore/version-pins-refresh; PR=0 keeps the bump local + (#538) - README core-value section rewritten around operator outcomes — full-speed YOLO, host out of vendor code's reach, explicit boundary, identity as a launch flag, official CLIs stock, zero workflow tax diff --git a/DEV-LOGS.md b/DEV-LOGS.md index d90710c..281ebbd 100644 --- a/DEV-LOGS.md +++ b/DEV-LOGS.md @@ -13,6 +13,11 @@ - Minimal markdown markers, no unnecessary formatting, minimal emojis. - Reference issue numbers in the format `#` for easy linking. +# [2026-08-05] Dev Log: versions-up pins what it built and PRs the bump #538 +- Why: team flow was `versions-up && versions-pin && build-cloak` plus a hand-written PR. versions-pin re-fetches upstream AFTER the build, so pins could drift to a version never build-tested (claude-code ships several times a day). Cloak wasn't covered by versions-up at all. +- What: version-upgrade.sh now builds cloak too (cloakbrowser wrapper resolved from npm, joins the manifest and --only), then writes versions.env from the exact resolved build args — write_version_pins moved to shared version-pins.sh, round-trip guard intact — and calls new scripts/versions-pr.sh: commit in a temp worktree detached at origin/main (push HEAD:chore/version-pins-refresh, user checkout untouched), gh pr create unless one is already open. PR=0 opts out; a failed PR stage warns and exits 0 — a dead push must not sink a finished build. Tests: cloak build args, pin write, outage no-write, PR soft-fail in tests/version-upgrade.sh; hermetic real-git-fake-gh suite in tests/versions-pr.sh. +- Result: one command replaces the 3-command dance and pins are guaranteed build-tested. versions-pin stays for pin-only refresh; build-cloak stays standalone. + # [2026-07-28] Dev Log: home dir chown race bricks containers #506 - Why: intermittent `env: 'claude': Permission denied` on fresh containers. /home/deva stuck at build UID 1001 mode 750 (noble HOME_MODE) after remap to host UID — user can't traverse its own home. usermod's implicit home-tree chown walks live host mounts (~/.claude churning under concurrent sessions), aborts mid-walk with rc=12 AFTER updating passwd; shadow chowns the top dir last, so it never gets fixed. The 7511464 whitelist chowns subdirs, never $DEVA_HOME itself. Latent since 5807889 dropped the recursive home chown; only bites when the walk races live mounts, which is why sibling containers were fine. - What: explicit non-recursive `chown "$DEVA_UID:$DEVA_GID" "$DEVA_HOME"` in setup_nonroot_user, after the usermod block, using the adapted DEVA_UID so the usermod-failed-entirely variant stays consistent. Devlog with full forensics in docs/devlog/20260728-home-dir-chown-race.org. Verified by fault injection: stub usermod (passwd updated, chown skipped, exit 12) reproduces the brick unpatched, comes out clean patched. diff --git a/Makefile b/Makefile index 5c9eef7..1126602 100644 --- a/Makefile +++ b/Makefile @@ -282,9 +282,12 @@ versions-up: BUILD_IMAGE=$(MAIN_IMAGE) \ CORE_IMAGE=$(CORE_IMAGE) \ RUST_IMAGE=$(RUST_IMAGE) \ + CLOAK_IMAGE=$(CLOAK_IMAGE) \ DOCKERFILE=$(DOCKERFILE) \ RUST_DOCKERFILE=$(RUST_DOCKERFILE) \ + CLOAK_DOCKERFILE=$(CLOAK_DOCKERFILE) \ ONLY=$(ONLY) \ + $(if $(PR),PR=$(PR)) \ $(VERSION_QUERY_OVERRIDES) \ ./scripts/version-upgrade.sh @@ -455,8 +458,10 @@ help: @echo " buildx-multi-rust Build multi-arch Rust and push" @echo " toolchains List pinned toolchains and managed build tools" @echo " versions Compare built vs latest versions with changelogs" - @echo " versions-up Build both images with latest upstream agent versions" + @echo " versions-up Build all images at latest upstream versions," + @echo " pin the built versions, and PR the bump" @echo " ONLY=cctrace upgrades one tool, rest stay pinned" + @echo " PR=0 skips the auto commit + pull request" @echo " versions-pin Refresh $(VERSION_PINS_FILE) from upstream" @echo " CHANGELOG=1 also shows changelogs for updated tools" @echo " scripts List repo helper scripts" @@ -514,5 +519,6 @@ help: @echo " make versions-pin CHANGELOG=1 # Refresh pins + show changelogs" @echo " make versions # Check current versions" @echo " make PLAYWRIGHT_VERSION=1.60.0 build-rust # Override rust browser tooling" - @echo " make versions-up # Upgrade to latest upstream versions" + @echo " make versions-up # Build latest, pin what was built, PR the bump" + @echo " make versions-up PR=0 # Same, but keep the pin bump local" @echo " make versions-up ONLY=cctrace # Upgrade just cctrace, rest pinned" diff --git a/scripts/update-version-pins.sh b/scripts/update-version-pins.sh index b525ea8..685df2a 100755 --- a/scripts/update-version-pins.sh +++ b/scripts/update-version-pins.sh @@ -184,40 +184,6 @@ show_changelogs() { fi } -# ── Write versions.env ─────────────────────────────────────────────────── - -write_version_pins() { - cat > "$VERSION_PINS_FILE" < "$VERSION_PINS_FILE" <&2 + cloak_wrapper_ver="$_pin_cloak_wrapper" + fi + fi + local _wrapper_stale=0 + [[ "$(normalize_version "$cloak_wrapper_ver")" != "$(normalize_version "$_pin_cloak_wrapper")" ]] && _wrapper_stale=1 + if print_version_summary; then - echo -e "${GREEN}All versions up-to-date. Nothing to upgrade.${RESET}" - exit 0 + if [[ $_wrapper_stale -eq 0 ]]; then + echo -e "${GREEN}All versions up-to-date. Nothing to upgrade.${RESET}" + exit 0 + fi + echo -e "${CYAN}Agent CLIs up-to-date; cloakbrowser wrapper moved ${_pin_cloak_wrapper} -> ${cloak_wrapper_ver}.${RESET}" fi # --only: gate on the selected tools, not the whole manifest — a lagging @@ -155,6 +184,10 @@ main() { if [[ -n $ONLY ]]; then local _t _cur _lat _only_needs_update=0 for _t in ${ONLY//,/ }; do + if [[ $_t == cloakbrowser ]]; then + [[ $_wrapper_stale -eq 1 ]] && _only_needs_update=1 + continue + fi _cur=$(normalize_version "$(get_current "$_t")") _lat=$(normalize_version "$(get_latest "$_t")") if [[ -z $_cur || $_cur == "-" || $_cur != "$_lat" ]]; then @@ -209,6 +242,7 @@ main() { "CCX|ccx_ver|_CLI_CCX|ccx" "Copilot API|copilot_ver|_CLI_COPILOT|copilot-api" "Playwright|playwright_ver|_CLI_PLAYWRIGHT|playwright" + "CloakBrowser|cloak_wrapper_ver|_CLI_CLOAKBROWSER|cloakbrowser" ) local _lines_upgrade=() _lines_pinned=() _lines_current=() _lines_new=() @@ -218,8 +252,15 @@ main() { IFS='|' read -r _label _var _cli_var _tool <<< "$_mp" local _val=${!_var:-} local _cli_val=${!_cli_var:-} - local _cur=$(get_current "$_tool") - local _type=$(get_tool_field "$_tool" type) + local _cur _type + if [[ $_tool == cloakbrowser ]]; then + # Not in the registry: current = the versions.env pin. + _cur="$_pin_cloak_wrapper" + _type="npm" + else + _cur=$(get_current "$_tool") + _type=$(get_tool_field "$_tool" type) + fi local _pad=$(printf "%-14s" "$_label") local _fmt_val _fmt_cur @@ -344,9 +385,45 @@ main() { --build-arg RUST_TARGETS="$RUST_TARGETS" \ -t "$RUST_IMAGE" . + echo "" + section "Building Cloak Image" + docker build -f "$CLOAK_DOCKERFILE" \ + ${PROXY_ARGS[@]+"${PROXY_ARGS[@]}"} \ + --build-arg BASE_IMAGE="$RUST_IMAGE" \ + --build-arg CLOAKBROWSER_WRAPPER_VERSION="$cloak_wrapper_ver" \ + -t "$CLOAK_IMAGE" . + echo "" echo -e "${GREEN}${BOLD}All images upgraded successfully${RESET}" echo -e "${DIM}Completed: $(date '+%Y-%m-%d %H:%M:%S')${RESET}" + + # Pin exactly what was built. A re-fetch here could pick up a version + # published mid-build and pin something never build-tested. + echo "" + section "Pinning versions.env" + CLAUDE_CODE_VERSION="$claude_ver" + CCTRACE_VERSION="$cctrace_ver" + CODEX_VERSION="$codex_ver" + GEMINI_CLI_VERSION="$gemini_ver" + GROK_CLI_VERSION="$grok_ver" + KIMI_CODE_VERSION="$kimi_ver" + CCX_VERSION="$ccx_ver" + COPILOT_API_VERSION="$copilot_ver" + PLAYWRIGHT_VERSION="$playwright_ver" + CLOAKBROWSER_WRAPPER_VERSION="$cloak_wrapper_ver" + write_version_pins + echo -e "${GREEN}Wrote ${VERSION_PINS_FILE##*/} from the built versions${RESET}" + + if [[ $PR == 0 ]]; then + echo -e "${DIM}PR=0: skipping pin commit + pull request${RESET}" + return 0 + fi + echo "" + section "Opening Pin PR" + if ! bash "$SCRIPT_DIR/versions-pr.sh"; then + echo -e "${YELLOW}PR creation failed; pins are written locally.${RESET}" >&2 + echo -e "${YELLOW}Retry with: ./scripts/versions-pr.sh${RESET}" >&2 + fi } main diff --git a/scripts/versions-pr.sh b/scripts/versions-pr.sh new file mode 100755 index 0000000..39038cd --- /dev/null +++ b/scripts/versions-pr.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# versions-pr.sh - Commit the versions.env bump and open the pin PR +# +# Builds the commit in a temp worktree detached at REMOTE/BASE_BRANCH so +# the caller's checkout, branch, and staged files are never touched. The +# push targets refs/heads/PR_BRANCH directly from detached HEAD — no +# local branch is created, so repeat runs cannot collide with one. +# +# The bump branch is throwaway by contract: a force-push replacing a +# stale unmerged sweep is the intended behavior, not data loss. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/version-pins.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/release-utils.sh" + +REPO_ROOT=${REPO_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)} +REMOTE=${REMOTE:-origin} +BASE_BRANCH=${BASE_BRANCH:-main} +PR_BRANCH=${PR_BRANCH:-chore/version-pins-refresh} +PINS_NAME=versions.env + +usage() { + cat <<'EOF' +Usage: versions-pr.sh [-h|--help] + +Commit the local versions.env bump on a fresh branch off REMOTE/BASE_BRANCH +and open (or update) the pin pull request. No-op when the local pins +already match the remote base branch. + +Environment: + REMOTE Git remote to push to (default: origin) + BASE_BRANCH PR base branch (default: main) + PR_BRANCH Head branch, force-pushed (default: chore/version-pins-refresh) +EOF +} + +case "${1:-}" in + -h|--help) usage; exit 0 ;; + "") ;; + *) echo "error: unknown option: $1" >&2; usage >&2; exit 1 ;; +esac + +for cmd in git gh; do + command -v "$cmd" >/dev/null || { echo "error: $cmd not found" >&2; exit 1; } +done + +# Human-facing pin names for commit/PR bodies (grok, not GROK_CLI_VERSION). +pin_label() { + case $1 in + NODE_MAJOR) echo "node" ;; + GO_VERSION) echo "go" ;; + PYTHON_VERSION) echo "python" ;; + DELTA_VERSION) echo "delta" ;; + TMUX_VERSION) echo "tmux" ;; + TMUX_SHA256) echo "tmux-sha256" ;; + CLAUDE_CODE_VERSION) echo "claude-code" ;; + CCTRACE_VERSION) echo "cctrace" ;; + CODEX_VERSION) echo "codex" ;; + GEMINI_CLI_VERSION) echo "gemini-cli" ;; + GROK_CLI_VERSION) echo "grok" ;; + KIMI_CODE_VERSION) echo "kimi-code" ;; + CCX_VERSION) echo "ccx" ;; + COPILOT_API_VERSION) echo "copilot-api" ;; + PLAYWRIGHT_VERSION) echo "playwright" ;; + CLOAKBROWSER_WRAPPER_VERSION) echo "cloakbrowser" ;; + *) echo "$1" | tr '[:upper:]_' '[:lower:]-' ;; + esac +} + +# Commit hashes read better short; semvers pass through unchanged. +short_val() { + if [[ $1 =~ ^[0-9a-f]{40}$ ]]; then + echo "${1:0:7}" + else + echo "$1" + fi +} + +main() { + cd "$REPO_ROOT" + + git fetch --quiet "$REMOTE" "$BASE_BRANCH" + local base_ref="refs/remotes/$REMOTE/$BASE_BRANCH" + + if git diff --quiet "$base_ref" -- "$PINS_NAME"; then + echo -e "${GREEN}Pins already match ${REMOTE}/${BASE_BRANCH}. Nothing to PR.${RESET}" + return 0 + fi + + # Bump list: compare each pin var between the base branch and the + # working copy. Drives both the commit body and the PR body. + local old_pins bumps=() + old_pins=$(git show "$base_ref:$PINS_NAME") + local var old new + for var in "${VERSION_PIN_VARS[@]}"; do + old=$(sed -n "s/^$var=//p" <<< "$old_pins") + new=$(sed -n "s/^$var=//p" < "$PINS_NAME") + if [[ -n $new && $old != "$new" ]]; then + bumps+=("- $(pin_label "$var") $(short_val "${old:-none}") -> $(short_val "$new")") + fi + done + + if [[ ${#bumps[@]} -eq 0 ]]; then + # File differs but no pin moved: comments/layout drift. A pin PR + # for that would be noise — leave it to a deliberate commit. + echo -e "${YELLOW}versions.env differs from ${REMOTE}/${BASE_BRANCH} but no pin changed; skipping PR.${RESET}" + return 0 + fi + + local bump_list + bump_list=$(printf '%s\n' "${bumps[@]}") + echo -e "${CYAN}Pin bump vs ${REMOTE}/${BASE_BRANCH}:${RESET}" + echo "$bump_list" + + # Deliberately not local: the EXIT trap runs after main() returns, + # when locals are already gone. + tmp=$(mktemp -d) + wt="$tmp/wt" + cleanup() { + git worktree remove --force "$wt" 2>/dev/null || true + rm -rf "$tmp" + } + trap cleanup EXIT + + git worktree add --quiet --detach "$wt" "$base_ref" + cp "$PINS_NAME" "$wt/$PINS_NAME" + git -C "$wt" add "$PINS_NAME" + git -C "$wt" commit --quiet -m "$(printf 'chore(versions): refresh agent CLI pins\n\n%s' "$bump_list")" + git -C "$wt" push --quiet --force "$REMOTE" "HEAD:refs/heads/$PR_BRANCH" + echo -e "${GREEN}Pushed ${PR_BRANCH} to ${REMOTE}${RESET}" + + local pr_url + pr_url=$(gh pr list --head "$PR_BRANCH" --base "$BASE_BRANCH" --state open --json url --jq '.[0].url // empty') + if [[ -n $pr_url ]]; then + echo -e "${GREEN}Pin PR already open, branch updated: ${pr_url}${RESET}" + return 0 + fi + + pr_url=$(gh pr create \ + --base "$BASE_BRANCH" \ + --head "$PR_BRANCH" \ + --title "chore(versions): refresh agent CLI pins" \ + --body "$(printf 'Automated pin sweep from make versions-up. Images (core, main,\nrust, cloak) were built locally at these exact versions before\npinning:\n\n%s' "$bump_list")") + echo -e "${GREEN}Opened pin PR: ${pr_url}${RESET}" +} + +main diff --git a/tests/version-upgrade.sh b/tests/version-upgrade.sh index 9884212..72146b5 100644 --- a/tests/version-upgrade.sh +++ b/tests/version-upgrade.sh @@ -106,6 +106,7 @@ case "$url" in */-/package/@xai-official/grok/dist-tags) echo '{"latest":"0.2.93"}' ;; */-/package/@moonshot-ai/kimi-code/dist-tags) echo '{"latest":"0.28.0"}' ;; */-/package/playwright/dist-tags) echo '{"latest":"1.60.0"}' ;; +*/-/package/cloakbrowser/dist-tags) echo '{"latest":"0.6.0"}' ;; *registry.npmjs.org/@anthropic-ai%2fclaude-code) echo '{"time":{"2.1.87":"2026-03-29T01:40:00Z"}}' ;; *registry.npmjs.org/@thevibeworks%2fcctrace) echo '{"time":{"0.4.0":"2026-03-29T01:40:00Z"}}' ;; *registry.npmjs.org/@openai%2fcodex) echo '{"time":{"0.117.0":"2026-03-26T22:28:00Z"}}' ;; @@ -122,13 +123,23 @@ EOF chmod +x "$FAKE_BIN/docker" "$FAKE_BIN/npm" "$FAKE_BIN/gh" "$FAKE_BIN/curl" +# versions-up writes VERSION_PINS_FILE after a successful build — every +# invocation must point it at a scratch copy or the test clobbers the +# repo's real versions.env. PR=0 keeps the git/PR stage out of scope here +# (versions-pr.sh has its own hermetic test). +MAIN_PINS="$TMP_ROOT/pins-main.env" +cp "$REPO_ROOT/versions.env" "$MAIN_PINS" + PATH="$FAKE_BIN:$PATH" \ DOCKER_BUILD_LOG="$DOCKER_BUILD_LOG" \ +VERSION_PINS_FILE="$MAIN_PINS" \ +PR=0 \ AUTO_YES=1 \ CHECK_IMAGE="ghcr.io/thevibeworks/deva:rust" \ BUILD_IMAGE="ghcr.io/thevibeworks/deva:latest" \ CORE_IMAGE="ghcr.io/thevibeworks/deva:core" \ RUST_IMAGE="ghcr.io/thevibeworks/deva:rust" \ +CLOAK_IMAGE="ghcr.io/thevibeworks/deva:cloak" \ GO_VERSION="1.26.2" \ CCTRACE_VERSION="0.4.0" \ PLAYWRIGHT_VERSION="1.60.0" \ @@ -137,10 +148,12 @@ PLAYWRIGHT_VERSION="1.60.0" \ core_build="$(sed -n '1p' "$DOCKER_BUILD_LOG")" main_build="$(sed -n '2p' "$DOCKER_BUILD_LOG")" rust_build="$(sed -n '3p' "$DOCKER_BUILD_LOG")" +cloak_build="$(sed -n '4p' "$DOCKER_BUILD_LOG")" [[ -n "$core_build" ]] || { echo "missing core build invocation" >&2; exit 1; } [[ -n "$main_build" ]] || { echo "missing main build invocation" >&2; exit 1; } [[ -n "$rust_build" ]] || { echo "missing rust build invocation" >&2; exit 1; } +[[ -n "$cloak_build" ]] || { echo "missing cloak build invocation" >&2; exit 1; } for expected in \ "--target agent-base" \ @@ -188,10 +201,46 @@ do } done +for expected in \ + "--build-arg BASE_IMAGE=ghcr.io/thevibeworks/deva:rust" \ + "--build-arg CLOAKBROWSER_WRAPPER_VERSION=0.6.0" \ + "-t ghcr.io/thevibeworks/deva:cloak ." +do + [[ "$cloak_build" == *"$expected"* ]] || { + echo "cloak build missing expected arg: $expected" >&2 + exit 1 + } +done + +# ───── pins written from the BUILT versions, not a second fetch ───── +for expected in \ + "CLAUDE_CODE_VERSION=2.1.87" \ + "CCTRACE_VERSION=0.4.0" \ + "CODEX_VERSION=0.117.0" \ + "GEMINI_CLI_VERSION=0.35.3" \ + "GROK_CLI_VERSION=0.2.93" \ + "KIMI_CODE_VERSION=0.28.0" \ + "CCX_VERSION=v0.7.0" \ + "COPILOT_API_VERSION=0ea08febdd7e3e055b03dd298bf57e669500b5c1" \ + "PLAYWRIGHT_VERSION=1.60.0" \ + "CLOAKBROWSER_WRAPPER_VERSION=0.6.0" \ + "GO_VERSION=1.26.2" +do + grep -qx "$expected" "$MAIN_PINS" || { + echo "pin file missing built version: $expected" >&2 + cat "$MAIN_PINS" >&2 + exit 1 + } +done + # ───── proxied build: localhost rewrite + host-gateway + redacted logs ───── PROXY_BUILD_LOG="$TMP_ROOT/docker-build-proxy.log" +PROXY_PINS="$TMP_ROOT/pins-proxy.env" +cp "$REPO_ROOT/versions.env" "$PROXY_PINS" proxy_out="$(PATH="$FAKE_BIN:$PATH" \ DOCKER_BUILD_LOG="$PROXY_BUILD_LOG" \ +VERSION_PINS_FILE="$PROXY_PINS" \ +PR=0 \ AUTO_YES=1 \ HTTP_PROXY="http://user:secret@127.0.0.1:7890" \ HTTPS_PROXY="http://localhost:7890" \ @@ -199,6 +248,7 @@ CHECK_IMAGE="ghcr.io/thevibeworks/deva:rust" \ BUILD_IMAGE="ghcr.io/thevibeworks/deva:latest" \ CORE_IMAGE="ghcr.io/thevibeworks/deva:core" \ RUST_IMAGE="ghcr.io/thevibeworks/deva:rust" \ +CLOAK_IMAGE="ghcr.io/thevibeworks/deva:cloak" \ GO_VERSION="1.26.2" \ CCTRACE_VERSION="0.4.0" \ PLAYWRIGHT_VERSION="1.60.0" \ @@ -233,13 +283,18 @@ EOF chmod +x "$FAKE_BIN/curl" OUTAGE_BUILD_LOG="$TMP_ROOT/docker-build-outage.log" +OUTAGE_PINS="$TMP_ROOT/pins-outage.env" +cp "$REPO_ROOT/versions.env" "$OUTAGE_PINS" if ! outage_out="$(PATH="$FAKE_BIN:$PATH" \ DOCKER_BUILD_LOG="$OUTAGE_BUILD_LOG" \ +VERSION_PINS_FILE="$OUTAGE_PINS" \ +PR=0 \ AUTO_YES=1 \ CHECK_IMAGE="ghcr.io/thevibeworks/deva:rust" \ BUILD_IMAGE="ghcr.io/thevibeworks/deva:latest" \ CORE_IMAGE="ghcr.io/thevibeworks/deva:core" \ RUST_IMAGE="ghcr.io/thevibeworks/deva:rust" \ +CLOAK_IMAGE="ghcr.io/thevibeworks/deva:cloak" \ GO_VERSION="1.26.2" \ CCTRACE_VERSION="0.4.0" \ PLAYWRIGHT_VERSION="1.60.0" \ @@ -262,6 +317,10 @@ if [[ -s "$OUTAGE_BUILD_LOG" ]]; then echo "no builds should run during a registry outage" >&2 exit 1 fi +if ! diff -u "$REPO_ROOT/versions.env" "$OUTAGE_PINS" >/dev/null; then + echo "registry outage must not rewrite the pin file" >&2 + exit 1 +fi # ───── update-version-pins: write_version_pins round-trips versions.env ───── # The heredoc in write_version_pins is a second copy of the file layout: a @@ -287,3 +346,58 @@ if ! diff -u "$REPO_ROOT/versions.env" "$PINS_COPY"; then echo "write_version_pins does not round-trip versions.env: pins or comments lost" >&2 exit 1 fi + +# ───── PR stage soft-fails: a dead git must not sink a finished build ───── +# Working curl again, but git still exits 1 (from the round-trip block): +# versions-pr.sh dies on fetch, version-upgrade.sh must warn and exit 0. +cat >"$FAKE_BIN/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +url="${!#}" +case "$url" in +*/-/package/@anthropic-ai/claude-code/dist-tags) echo '{"latest":"2.1.87"}' ;; +*/-/package/@thevibeworks/cctrace/dist-tags) echo '{"latest":"0.4.0"}' ;; +*/-/package/@openai/codex/dist-tags) echo '{"latest":"0.117.0"}' ;; +*/-/package/@google/gemini-cli/dist-tags) echo '{"latest":"0.35.3"}' ;; +*/-/package/@xai-official/grok/dist-tags) echo '{"latest":"0.2.93"}' ;; +*/-/package/@moonshot-ai/kimi-code/dist-tags) echo '{"latest":"0.28.0"}' ;; +*/-/package/playwright/dist-tags) echo '{"latest":"1.60.0"}' ;; +*/-/package/cloakbrowser/dist-tags) echo '{"latest":"0.6.0"}' ;; +*registry.npmjs.org/*) echo '{"time":{}}' ;; +*) + echo "unexpected curl url: $url" >&2 + exit 1 + ;; +esac +EOF +chmod +x "$FAKE_BIN/curl" + +PRFAIL_BUILD_LOG="$TMP_ROOT/docker-build-prfail.log" +PRFAIL_PINS="$TMP_ROOT/pins-prfail.env" +cp "$REPO_ROOT/versions.env" "$PRFAIL_PINS" +if ! prfail_out="$(PATH="$FAKE_BIN:$PATH" \ +DOCKER_BUILD_LOG="$PRFAIL_BUILD_LOG" \ +VERSION_PINS_FILE="$PRFAIL_PINS" \ +AUTO_YES=1 \ +CHECK_IMAGE="ghcr.io/thevibeworks/deva:rust" \ +BUILD_IMAGE="ghcr.io/thevibeworks/deva:latest" \ +CORE_IMAGE="ghcr.io/thevibeworks/deva:core" \ +RUST_IMAGE="ghcr.io/thevibeworks/deva:rust" \ +CLOAK_IMAGE="ghcr.io/thevibeworks/deva:cloak" \ +GO_VERSION="1.26.2" \ +CCTRACE_VERSION="0.4.0" \ +PLAYWRIGHT_VERSION="1.60.0" \ +"$REPO_ROOT/scripts/version-upgrade.sh" 2>&1)"; then + echo "a failed PR stage must not fail versions-up after a good build" >&2 + echo "$prfail_out" >&2 + exit 1 +fi +if ! grep -F -- "PR creation failed" <<<"$prfail_out" >/dev/null; then + echo "expected PR soft-fail warning in output" >&2 + echo "$prfail_out" >&2 + exit 1 +fi +grep -qx "CLAUDE_CODE_VERSION=2.1.87" "$PRFAIL_PINS" || { + echo "pins must still be written when the PR stage fails" >&2 + exit 1 +} diff --git a/tests/versions-pr.sh b/tests/versions-pr.sh new file mode 100755 index 0000000..01eee0b --- /dev/null +++ b/tests/versions-pr.sh @@ -0,0 +1,133 @@ +#!/usr/bin/env bash +# Hermetic test for scripts/versions-pr.sh: real git against a scratch +# bare origin, fake gh. The user checkout (branch, dirty files) must +# come out untouched — the commit happens in a throwaway worktree. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +TMP_ROOT="$(mktemp -d)" +FAKE_BIN="$TMP_ROOT/bin" +GH_LOG="$TMP_ROOT/gh.log" +mkdir -p "$FAKE_BIN" + +cleanup() { + rm -rf "$TMP_ROOT" +} +trap cleanup EXIT + +fail() { + echo "$1" >&2 + exit 1 +} + +# GH_PR_LIST_URL switches the open-PR lookup between "none" and "exists". +cat >"$FAKE_BIN/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"$GH_LOG" +case "${1:-} ${2:-}" in +"pr list") echo "${GH_PR_LIST_URL:-}" ;; +"pr create") echo "https://github.com/thevibeworks/deva/pull/999" ;; +*) + echo "unexpected gh invocation: $*" >&2 + exit 1 + ;; +esac +EOF +chmod +x "$FAKE_BIN/gh" + +# ───── scratch origin: bare repo seeded with the real versions.env ───── +ORIGIN="$TMP_ROOT/origin.git" +SEED="$TMP_ROOT/seed" +git init --quiet --bare --initial-branch=main "$ORIGIN" +git init --quiet --initial-branch=main "$SEED" +git -C "$SEED" config user.name test +git -C "$SEED" config user.email test@test +cp "$REPO_ROOT/versions.env" "$SEED/versions.env" +git -C "$SEED" add versions.env +git -C "$SEED" commit --quiet -m "seed" +git -C "$SEED" remote add origin "$ORIGIN" +git -C "$SEED" push --quiet origin main + +# ───── user checkout: on a WIP branch with dirty state ───── +CLONE="$TMP_ROOT/clone" +git clone --quiet "$ORIGIN" "$CLONE" +git -C "$CLONE" config user.name test +git -C "$CLONE" config user.email test@test +git -C "$CLONE" switch --quiet -c my-wip +echo junk >"$CLONE/wip.txt" + +PR_SCRIPT="$REPO_ROOT/scripts/versions-pr.sh" + +run_pr() { + PATH="$FAKE_BIN:$PATH" \ + GH_LOG="$GH_LOG" \ + GH_PR_LIST_URL="${GH_PR_LIST_URL:-}" \ + REPO_ROOT="$CLONE" \ + bash "$PR_SCRIPT" 2>&1 +} + +# ───── 1. pins match upstream: no-op, no branch pushed ───── +out="$(run_pr)" || fail "no-op run must exit 0: $out" +grep -F -- "Pins already match" <<<"$out" >/dev/null || fail "expected no-op message, got: $out" +if git -C "$ORIGIN" show-ref --verify --quiet refs/heads/chore/version-pins-refresh; then + fail "no-op run must not push a branch" +fi + +# ───── 2. comment-only drift: skip, no branch pushed ───── +echo "# trailing comment for drift test" >>"$CLONE/versions.env" +out="$(run_pr)" || fail "comment-drift run must exit 0: $out" +grep -F -- "no pin changed; skipping PR" <<<"$out" >/dev/null || fail "expected comment-drift skip, got: $out" +if git -C "$ORIGIN" show-ref --verify --quiet refs/heads/chore/version-pins-refresh; then + fail "comment-only drift must not push a branch" +fi +git -C "$CLONE" checkout --quiet versions.env + +# ───── 3. real bump: branch pushed, commit body lists bumps, PR created ───── +sed -i \ + -e 's/^CLAUDE_CODE_VERSION=.*/CLAUDE_CODE_VERSION=9.9.9/' \ + -e 's/^COPILOT_API_VERSION=.*/COPILOT_API_VERSION=deadbeefdeadbeefdeadbeefdeadbeefdeadbeef/' \ + "$CLONE/versions.env" + +out="$(run_pr)" || fail "bump run failed: $out" +grep -F -- "Opened pin PR" <<<"$out" >/dev/null || fail "expected PR-created message, got: $out" + +git -C "$ORIGIN" show-ref --verify --quiet refs/heads/chore/version-pins-refresh || \ + fail "bump branch missing on origin" + +pushed_pins="$(git -C "$ORIGIN" show refs/heads/chore/version-pins-refresh:versions.env)" +grep -qx "CLAUDE_CODE_VERSION=9.9.9" <<<"$pushed_pins" || fail "pushed pins missing bumped claude-code" + +commit_msg="$(git -C "$ORIGIN" log -1 --format=%B refs/heads/chore/version-pins-refresh)" +grep -qx "chore(versions): refresh agent CLI pins" <<<"$(head -1 <<<"$commit_msg")" || \ + fail "wrong commit subject: $commit_msg" +grep -F -- "claude-code" <<<"$commit_msg" | grep -F -- "-> 9.9.9" >/dev/null || \ + fail "commit body missing claude-code bump: $commit_msg" +# 40-hex values are shortened for humans +grep -F -- "copilot-api" <<<"$commit_msg" | grep -F -- "-> deadbee" >/dev/null || \ + fail "commit body missing shortened copilot hash: $commit_msg" +if grep -F -- "deadbeefdeadbeef" <<<"$commit_msg" >/dev/null; then + fail "commit body must not carry full 40-char hashes" +fi + +grep -E -- "^pr create .*--base main .*--head chore/version-pins-refresh" "$GH_LOG" >/dev/null || \ + fail "gh pr create missing or malformed: $(cat "$GH_LOG")" + +# ───── 4. rerun with the PR already open: update branch, no second create ───── +: >"$GH_LOG" +out="$(GH_PR_LIST_URL="https://github.com/thevibeworks/deva/pull/999" run_pr)" || \ + fail "rerun with open PR failed: $out" +grep -F -- "already open" <<<"$out" >/dev/null || fail "expected already-open message, got: $out" +if grep -F -- "pr create" "$GH_LOG" >/dev/null; then + fail "must not open a second PR when one is already open" +fi + +# ───── user checkout untouched throughout ───── +[[ "$(git -C "$CLONE" branch --show-current)" == "my-wip" ]] || fail "user branch changed" +[[ -f "$CLONE/wip.txt" ]] || fail "user dirty file lost" +git -C "$CLONE" diff --quiet -- versions.env && fail "user versions.env edit lost" +[[ "$(git -C "$CLONE" worktree list | wc -l)" -eq 1 ]] || fail "leaked worktree in user checkout" + +echo "versions-pr tests passed" From 5e31c6d532cae3c3cd8856afca0c39e31614a3b9 Mon Sep 17 00:00:00 2001 From: Eric Wang Date: Thu, 6 Aug 2026 21:01:40 -0700 Subject: [PATCH 2/2] feat(agents): add opencode as the 6th agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - agents/opencode.sh: oauth mounts the XDG trio (.config/opencode, .local/share/opencode, .local/state/opencode); api-key passes OPENCODE_API_KEY only, mounts nothing, blank-overlays auth.json (it outranks the env key). Cache stays container-local. - deva.sh: first agent with nested canonical entries — mount path unchanged, opencode arms added to status display, autolink, config-home scaffold/warning, $HOME fallback, env scrub, auth tag - sandbox: OPENCODE_PERMISSION unlocks external-dir/doom-loop/.env asks (opencode is allow-by-default in-workspace); OPENCODE_DISABLE_AUTOUPDATE=1 since the image pins the CLI - --trace rejected until cctrace ships an opencode profile (thevibeworks/cctrace#89) - image/pins: opencode-ai OPENCODE_VERSION through versions.env, round-trip heredoc, Makefile, Dockerfiles, installer, registry, version-upgrade, CI/release/nightly workflows - tests: scripts/test-opencode-auth.sh (hermetic dry-run, 21 asserts); fixtures updated in install-tooling, release-utils, version-upgrade Close #541 Co-Authored-By: Claude Fable 5 --- .deva.example | 6 +- .github/workflows/ci.yml | 11 +++ .github/workflows/nightly-images.yml | 4 + .github/workflows/release.yml | 4 + CHANGELOG.md | 12 +++ DEV-LOGS.md | 5 ++ Dockerfile | 2 + Dockerfile.rust | 2 + Makefile | 17 +++- README.md | 5 +- README.zh-CN.md | 5 +- agents/opencode.sh | 85 ++++++++++++++++++++ agents/shared_auth.sh | 4 + deva.sh | 108 ++++++++++++++++++++++++-- docs/advanced-usage.md | 7 +- docs/authentication.md | 47 +++++++++++ docs/how-it-works.md | 10 ++- docs/index.md | 4 +- docs/philosophy.md | 2 +- docs/quick-start.md | 9 +++ docs/troubleshooting.md | 2 +- install.sh | 3 + llms.txt | 8 +- scripts/install-agent-tooling.sh | 14 +++- scripts/release-utils.sh | 1 + scripts/resolve-tool-versions.sh | 1 + scripts/test-install-agent-tooling.sh | 4 +- scripts/test-opencode-auth.sh | 102 ++++++++++++++++++++++++ scripts/update-version-pins.sh | 2 + scripts/version-pins.sh | 2 + scripts/version-upgrade.sh | 15 +++- scripts/versions-pr.sh | 1 + tests/test_release_utils.sh | 8 +- tests/version-upgrade.sh | 4 + versions.env | 1 + 35 files changed, 478 insertions(+), 39 deletions(-) create mode 100644 agents/opencode.sh create mode 100755 scripts/test-opencode-auth.sh diff --git a/.deva.example b/.deva.example index ff8a961..d450f6a 100644 --- a/.deva.example +++ b/.deva.example @@ -61,10 +61,12 @@ EPHEMERAL=false # Hybrid Agent Setup: # # Hybrid is the DEFAULT. deva walks every populated subdir under -# ~/.config/deva/ (claude, codex, gemini, grok, kimi) and mounts each agent's +# ~/.config/deva/ (claude, codex, gemini, grok, kimi, opencode) and mounts each agent's # canonical entries into the container. Populated = you either # hand-created the subdir or autolink symlinked it from legacy -# ~/.claude, ~/.codex, ~/.gemini, ~/.grok, ~/.kimi-code on first run. +# ~/.claude, ~/.codex, ~/.gemini, ~/.grok, ~/.kimi-code, or the opencode +# XDG dirs (~/.config/opencode + ~/.local/share/opencode + +# ~/.local/state/opencode) on first run. # # No .deva entries required for the common case. To opt OUT of # hybrid for a single invocation, pass --config-home DIR to diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b18a54..9e6082a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,7 @@ jobs: --build-arg GEMINI_CLI_VERSION="${{ steps.pins.outputs.gemini_cli_version }}" \ --build-arg GROK_CLI_VERSION="${{ steps.pins.outputs.grok_cli_version }}" \ --build-arg KIMI_CODE_VERSION="${{ steps.pins.outputs.kimi_code_version }}" \ + --build-arg OPENCODE_VERSION="${{ steps.pins.outputs.opencode_version }}" \ --build-arg CCX_VERSION="${{ steps.pins.outputs.ccx_version }}" \ --build-arg COPILOT_API_VERSION="${{ steps.pins.outputs.copilot_api_version }}" \ . @@ -104,6 +105,7 @@ jobs: GEMINI_CLI_VERSION="${{ steps.pins.outputs.gemini_cli_version }}" \ GROK_CLI_VERSION="${{ steps.pins.outputs.grok_cli_version }}" \ KIMI_CODE_VERSION="${{ steps.pins.outputs.kimi_code_version }}" \ + OPENCODE_VERSION="${{ steps.pins.outputs.opencode_version }}" \ CCX_VERSION="${{ steps.pins.outputs.ccx_version }}" \ COPILOT_API_VERSION="${{ steps.pins.outputs.copilot_api_version }}" \ PLAYWRIGHT_VERSION="${{ steps.pins.outputs.playwright_version }}" \ @@ -130,6 +132,7 @@ jobs: deva.sh gemini -Q -- --version deva.sh grok -Q -- --version deva.sh kimi -Q -- --version + deva.sh opencode -Q -- --version - name: Smoke Claude --chrome mount assembly shell: bash @@ -183,6 +186,14 @@ jobs: DEVA_DOCKER_TAG=ci \ ./scripts/test-kimi-auth.sh + - name: Smoke opencode auth wiring + shell: bash + run: | + set -euo pipefail + DEVA_DOCKER_IMAGE=deva-smoke \ + DEVA_DOCKER_TAG=ci \ + ./scripts/test-opencode-auth.sh + - name: Smoke version targets shell: bash run: | diff --git a/.github/workflows/nightly-images.yml b/.github/workflows/nightly-images.yml index ee91900..b8d4109 100644 --- a/.github/workflows/nightly-images.yml +++ b/.github/workflows/nightly-images.yml @@ -56,6 +56,7 @@ jobs: gemini_cli_version: ${{ steps.versions.outputs.gemini_cli_version }} grok_cli_version: ${{ steps.versions.outputs.grok_cli_version }} kimi_code_version: ${{ steps.versions.outputs.kimi_code_version }} + opencode_version: ${{ steps.versions.outputs.opencode_version }} ccx_version: ${{ steps.versions.outputs.ccx_version }} copilot_api_version: ${{ steps.versions.outputs.copilot_api_version }} steps: @@ -84,6 +85,7 @@ jobs: - Gemini CLI: \`${{ steps.versions.outputs.gemini_cli_version }}\` - Grok CLI: \`${{ steps.versions.outputs.grok_cli_version }}\` - Kimi Code: \`${{ steps.versions.outputs.kimi_code_version }}\` + - opencode: \`${{ steps.versions.outputs.opencode_version }}\` - ccx: \`${{ steps.versions.outputs.ccx_version }}\` - Copilot API: \`${{ steps.versions.outputs.copilot_api_version }}\` - Stamp: \`${{ steps.versions.outputs.stamp }}\` @@ -146,6 +148,7 @@ jobs: GEMINI_CLI_VERSION=${{ needs.resolve-versions.outputs.gemini_cli_version }} GROK_CLI_VERSION=${{ needs.resolve-versions.outputs.grok_cli_version }} KIMI_CODE_VERSION=${{ needs.resolve-versions.outputs.kimi_code_version }} + OPENCODE_VERSION=${{ needs.resolve-versions.outputs.opencode_version }} CCX_VERSION=${{ needs.resolve-versions.outputs.ccx_version }} COPILOT_API_VERSION=${{ needs.resolve-versions.outputs.copilot_api_version }} @@ -201,6 +204,7 @@ jobs: GEMINI_CLI_VERSION=${{ needs.resolve-versions.outputs.gemini_cli_version }} GROK_CLI_VERSION=${{ needs.resolve-versions.outputs.grok_cli_version }} KIMI_CODE_VERSION=${{ needs.resolve-versions.outputs.kimi_code_version }} + OPENCODE_VERSION=${{ needs.resolve-versions.outputs.opencode_version }} CCX_VERSION=${{ needs.resolve-versions.outputs.ccx_version }} PLAYWRIGHT_VERSION=${{ needs.load-version-pins.outputs.playwright_version }} RUST_TOOLCHAINS=${{ needs.load-version-pins.outputs.rust_toolchains }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ad546c5..a22c3e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,6 +52,7 @@ jobs: gemini_cli_version: ${{ steps.pins.outputs.gemini_cli_version }} grok_cli_version: ${{ steps.pins.outputs.grok_cli_version }} kimi_code_version: ${{ steps.pins.outputs.kimi_code_version }} + opencode_version: ${{ steps.pins.outputs.opencode_version }} ccx_version: ${{ steps.pins.outputs.ccx_version }} copilot_api_version: ${{ steps.pins.outputs.copilot_api_version }} playwright_version: ${{ steps.pins.outputs.playwright_version }} @@ -89,6 +90,7 @@ jobs: - Gemini CLI: \`${{ steps.pins.outputs.gemini_cli_version }}\` - Grok CLI: \`${{ steps.pins.outputs.grok_cli_version }}\` - Kimi Code: \`${{ steps.pins.outputs.kimi_code_version }}\` + - opencode: \`${{ steps.pins.outputs.opencode_version }}\` - ccx: \`${{ steps.pins.outputs.ccx_version }}\` - Copilot API: \`${{ steps.pins.outputs.copilot_api_version }}\` - Playwright: \`${{ steps.pins.outputs.playwright_version }}\` @@ -154,6 +156,7 @@ jobs: GEMINI_CLI_VERSION=${{ needs.load-version-pins.outputs.gemini_cli_version }} GROK_CLI_VERSION=${{ needs.load-version-pins.outputs.grok_cli_version }} KIMI_CODE_VERSION=${{ needs.load-version-pins.outputs.kimi_code_version }} + OPENCODE_VERSION=${{ needs.load-version-pins.outputs.opencode_version }} CCX_VERSION=${{ needs.load-version-pins.outputs.ccx_version }} COPILOT_API_VERSION=${{ needs.load-version-pins.outputs.copilot_api_version }} @@ -211,6 +214,7 @@ jobs: GEMINI_CLI_VERSION=${{ needs.load-version-pins.outputs.gemini_cli_version }} GROK_CLI_VERSION=${{ needs.load-version-pins.outputs.grok_cli_version }} KIMI_CODE_VERSION=${{ needs.load-version-pins.outputs.kimi_code_version }} + OPENCODE_VERSION=${{ needs.load-version-pins.outputs.opencode_version }} CCX_VERSION=${{ needs.load-version-pins.outputs.ccx_version }} PLAYWRIGHT_VERSION=${{ needs.load-version-pins.outputs.playwright_version }} RUST_TOOLCHAINS=${{ needs.load-version-pins.outputs.rust_toolchains }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0558a4f..6d73d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- opencode (opencode.ai) as the 6th agent: `deva.sh opencode` (#541). + First XDG-native agent — auth/config persist across three nested + canonical entries (`.config/opencode`, `.local/share/opencode`, + `.local/state/opencode`) instead of one dot-dir; cache stays + container-local. Default `oauth` mounts the trio (auth.json carries + the subscription); `--auth-with api-key` passes `OPENCODE_API_KEY` + and mounts nothing. Container-is-the-sandbox via `OPENCODE_PERMISSION` + (opencode is already allow-by-default in-workspace); autoupdate off, + image pins `opencode-ai` (OPENCODE_VERSION). `--trace` rejected until + cctrace ships an opencode profile (thevibeworks/cctrace#89) + ### Changed - `make versions-up` is now the whole pin flow: builds core, main, rust, and cloak at latest upstream, writes versions.env from the diff --git a/DEV-LOGS.md b/DEV-LOGS.md index 281ebbd..86c25e0 100644 --- a/DEV-LOGS.md +++ b/DEV-LOGS.md @@ -13,6 +13,11 @@ - Minimal markdown markers, no unnecessary formatting, minimal emojis. - Reference issue numbers in the format `#` for easy linking. +# [2026-08-06] Dev Log: opencode as the 6th agent #541 +- Why: opencode's multi-provider client + zen/go subscription is worth having in the fleet, but it's the first XDG-native agent — state spans ~/.config/opencode, ~/.local/share/opencode (auth.json, sqlite session db), ~/.local/state/opencode instead of one dot-dir, which the canonical-entry machinery assumed. +- What: agents/opencode.sh (oauth mounts the XDG trio; api-key passes OPENCODE_API_KEY, mounts nothing, blank-overlays auth.json — it outranks the env key). agent_canonical_basenames grows nested entries; mount path needed no change (docker handles nested targets), but status display, autolink, config-home scaffold/warning, and the $HOME fallback each got opencode arms. Sandbox posture: opencode is allow-by-default in-workspace, so OPENCODE_PERMISSION only unlocks external_directory/doom_loop/.env-read asks; OPENCODE_DISABLE_AUTOUPDATE=1 since the image pins opencode-ai (OPENCODE_VERSION through versions.env, the round-trip heredoc, Makefile, Dockerfiles, CI/release/nightly, version-upgrade + registry). Cache dir deliberately not mounted (models.json + self-updater bin). --trace rejected with a pointer to thevibeworks/cctrace#89 (multi-provider wire needs per-host dialects; proposal filed). Tests: scripts/test-opencode-auth.sh (21 asserts, hermetic scratch-HOME dry-run), fixtures updated in test-install-agent-tooling, test_release_utils, tests/version-upgrade. +- Result: `deva.sh opencode` with subscription or service-account key; host prep = the three XDG dirs (cache excluded). Open: cctrace profile (cctrace#89), in-container device-code login flow untested against a real build until the next image bake. + # [2026-08-05] Dev Log: versions-up pins what it built and PRs the bump #538 - Why: team flow was `versions-up && versions-pin && build-cloak` plus a hand-written PR. versions-pin re-fetches upstream AFTER the build, so pins could drift to a version never build-tested (claude-code ships several times a day). Cloak wasn't covered by versions-up at all. - What: version-upgrade.sh now builds cloak too (cloakbrowser wrapper resolved from npm, joins the manifest and --only), then writes versions.env from the exact resolved build args — write_version_pins moved to shared version-pins.sh, round-trip guard intact — and calls new scripts/versions-pr.sh: commit in a temp worktree detached at origin/main (push HEAD:chore/version-pins-refresh, user checkout untouched), gh pr create unless one is already open. PR=0 opts out; a failed PR stage warns and exits 0 — a dead push must not sink a finished build. Tests: cloak build args, pin write, outage no-write, PR soft-fail in tests/version-upgrade.sh; hermetic real-git-fake-gh suite in tests/versions-pr.sh. diff --git a/Dockerfile b/Dockerfile index 96962b8..597958e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -213,6 +213,7 @@ ARG CODEX_VERSION=0.131.0 ARG GEMINI_CLI_VERSION=0.42.0 ARG GROK_CLI_VERSION=0.2.93 ARG KIMI_CODE_VERSION=0.28.0 +ARG OPENCODE_VERSION=1.18.14 # Record key tool versions as labels for quick inspection LABEL org.opencontainers.image.claude_code_version=${CLAUDE_CODE_VERSION} @@ -220,6 +221,7 @@ LABEL org.opencontainers.image.codex_version=${CODEX_VERSION} LABEL org.opencontainers.image.gemini_cli_version=${GEMINI_CLI_VERSION} LABEL org.opencontainers.image.grok_cli_version=${GROK_CLI_VERSION} LABEL org.opencontainers.image.kimi_code_version=${KIMI_CODE_VERSION} +LABEL org.opencontainers.image.opencode_version=${OPENCODE_VERSION} ARG CCX_VERSION=v0.7.0 diff --git a/Dockerfile.rust b/Dockerfile.rust index d10d48c..4920de9 100644 --- a/Dockerfile.rust +++ b/Dockerfile.rust @@ -13,6 +13,7 @@ ARG CODEX_VERSION=0.131.0 ARG GEMINI_CLI_VERSION=0.42.0 ARG GROK_CLI_VERSION=0.2.93 ARG KIMI_CODE_VERSION=0.28.0 +ARG OPENCODE_VERSION=1.18.14 ARG CCX_VERSION=v0.7.0 ARG PLAYWRIGHT_VERSION=1.60.0 ARG RUST_TOOLCHAINS="stable" @@ -24,6 +25,7 @@ LABEL org.opencontainers.image.codex_version=${CODEX_VERSION} LABEL org.opencontainers.image.gemini_cli_version=${GEMINI_CLI_VERSION} LABEL org.opencontainers.image.grok_cli_version=${GROK_CLI_VERSION} LABEL org.opencontainers.image.kimi_code_version=${KIMI_CODE_VERSION} +LABEL org.opencontainers.image.opencode_version=${OPENCODE_VERSION} LABEL org.opencontainers.image.ccx_version=${CCX_VERSION} LABEL org.opencontainers.image.playwright_version=${PLAYWRIGHT_VERSION} diff --git a/Makefile b/Makefile index 1126602..a830a1f 100644 --- a/Makefile +++ b/Makefile @@ -39,6 +39,7 @@ CODEX_VERSION ?= 0.131.0 GEMINI_CLI_VERSION ?= 0.42.0 GROK_CLI_VERSION ?= 0.2.93 KIMI_CODE_VERSION ?= 0.28.0 +OPENCODE_VERSION ?= 1.18.14 CCX_VERSION ?= v0.1.4 COPILOT_API_VERSION ?= 0ea08febdd7e3e055b03dd298bf57e669500b5c1 PLAYWRIGHT_VERSION ?= 1.60.0 @@ -90,6 +91,7 @@ AGENT_BUILD_ARGS := \ --build-arg GEMINI_CLI_VERSION=$(GEMINI_CLI_VERSION) \ --build-arg GROK_CLI_VERSION=$(GROK_CLI_VERSION) \ --build-arg KIMI_CODE_VERSION=$(KIMI_CODE_VERSION) \ + --build-arg OPENCODE_VERSION=$(OPENCODE_VERSION) \ --build-arg CCX_VERSION=$(CCX_VERSION) MAIN_BUILD_ARGS := $(TOOLCHAIN_BUILD_ARGS) $(AGENT_BUILD_ARGS) \ @@ -117,6 +119,7 @@ VERSION_QUERY_OVERRIDES := \ $(if $(filter command line environment environment\ override override,$(origin GEMINI_CLI_VERSION)),GEMINI_CLI_VERSION=$(GEMINI_CLI_VERSION)) \ $(if $(filter command line environment environment\ override override,$(origin GROK_CLI_VERSION)),GROK_CLI_VERSION=$(GROK_CLI_VERSION)) \ $(if $(filter command line environment environment\ override override,$(origin KIMI_CODE_VERSION)),KIMI_CODE_VERSION=$(KIMI_CODE_VERSION)) \ + $(if $(filter command line environment environment\ override override,$(origin OPENCODE_VERSION)),OPENCODE_VERSION=$(OPENCODE_VERSION)) \ $(if $(filter command line environment environment\ override override,$(origin CCX_VERSION)),CCX_VERSION=$(CCX_VERSION)) \ $(if $(filter command line environment environment\ override override,$(origin COPILOT_API_VERSION)),COPILOT_API_VERSION=$(COPILOT_API_VERSION)) \ $(if $(filter command line environment environment\ override override,$(origin PLAYWRIGHT_VERSION)),PLAYWRIGHT_VERSION=$(PLAYWRIGHT_VERSION)) \ @@ -162,15 +165,17 @@ build-main: build-network-check prev_gemini=$$(docker inspect --format='{{ index .Config.Labels "org.opencontainers.image.gemini_cli_version" }}' $(MAIN_IMAGE) 2>/dev/null || true); \ prev_grok=$$(docker inspect --format='{{ index .Config.Labels "org.opencontainers.image.grok_cli_version" }}' $(MAIN_IMAGE) 2>/dev/null || true); \ prev_kimi=$$(docker inspect --format='{{ index .Config.Labels "org.opencontainers.image.kimi_code_version" }}' $(MAIN_IMAGE) 2>/dev/null || true); \ + prev_opencode=$$(docker inspect --format='{{ index .Config.Labels "org.opencontainers.image.opencode_version" }}' $(MAIN_IMAGE) 2>/dev/null || true); \ fmt() { v="$$1"; if [ -z "$$v" ] || [ "$$v" = "" ]; then echo "-"; else case "$$v" in v*) echo "$$v";; *) echo "v$$v";; esac; fi; }; \ - curC=$$(fmt "$$prev_claude"); curX=$$(fmt "$$prev_codex"); curG=$$(fmt "$$prev_gemini"); curK=$$(fmt "$$prev_grok"); curKi=$$(fmt "$$prev_kimi"); \ - tgtC=$$(fmt "$(CLAUDE_CODE_VERSION)"); tgtX=$$(fmt "$(CODEX_VERSION)"); tgtG=$$(fmt "$(GEMINI_CLI_VERSION)"); tgtK=$$(fmt "$(GROK_CLI_VERSION)"); tgtKi=$$(fmt "$(KIMI_CODE_VERSION)"); \ - if [ "$$curC" = "$$tgtC" ] && [ "$$curX" = "$$tgtX" ] && [ "$$curG" = "$$tgtG" ] && [ "$$curK" = "$$tgtK" ] && [ "$$curKi" = "$$tgtKi" ]; then \ + curC=$$(fmt "$$prev_claude"); curX=$$(fmt "$$prev_codex"); curG=$$(fmt "$$prev_gemini"); curK=$$(fmt "$$prev_grok"); curKi=$$(fmt "$$prev_kimi"); curO=$$(fmt "$$prev_opencode"); \ + tgtC=$$(fmt "$(CLAUDE_CODE_VERSION)"); tgtX=$$(fmt "$(CODEX_VERSION)"); tgtG=$$(fmt "$(GEMINI_CLI_VERSION)"); tgtK=$$(fmt "$(GROK_CLI_VERSION)"); tgtKi=$$(fmt "$(KIMI_CODE_VERSION)"); tgtO=$$(fmt "$(OPENCODE_VERSION)"); \ + if [ "$$curC" = "$$tgtC" ] && [ "$$curX" = "$$tgtX" ] && [ "$$curG" = "$$tgtG" ] && [ "$$curK" = "$$tgtK" ] && [ "$$curKi" = "$$tgtKi" ] && [ "$$curO" = "$$tgtO" ]; then \ echo "Claude: $$tgtC (no change)"; \ echo "Codex: $$tgtX (no change)"; \ echo "Gemini: $$tgtG (no change)"; \ echo "Grok: $$tgtK (no change)"; \ echo "Kimi: $$tgtKi (no change)"; \ + echo "opencode: $$tgtO (no change)"; \ echo "Already up-to-date"; \ else \ if [ "$$curC" = "$$tgtC" ]; then \ @@ -198,6 +203,11 @@ build-main: build-network-check else \ echo "Kimi: $$curKi -> $$tgtKi"; \ fi; \ + if [ "$$curO" = "$$tgtO" ]; then \ + echo "opencode: $$tgtO (no change)"; \ + else \ + echo "opencode: $$curO -> $$tgtO"; \ + fi; \ fi @echo "Hint: override via GO_VERSION=... CLAUDE_CODE_VERSION=... or run 'make versions-pin'" docker build $(DOCKER_BUILD_FLAGS) -f $(DOCKERFILE) $(MAIN_BUILD_ARGS) -t $(MAIN_IMAGE) . @@ -496,6 +506,7 @@ help: @echo " GEMINI_CLI_VERSION Gemini CLI version (default: $(GEMINI_CLI_VERSION))" @echo " GROK_CLI_VERSION Grok CLI version (default: $(GROK_CLI_VERSION))" @echo " KIMI_CODE_VERSION Kimi Code CLI version (default: $(KIMI_CODE_VERSION))" + @echo " OPENCODE_VERSION opencode CLI version (default: $(OPENCODE_VERSION))" @echo " CCX_VERSION Atlas CLI version (default: $(CCX_VERSION))" @echo " PLAYWRIGHT_VERSION Playwright version (default: $(PLAYWRIGHT_VERSION))" @echo " CLOAKBROWSER_WRAPPER_VERSION CloakBrowser npm wrapper version (default: $(CLOAKBROWSER_WRAPPER_VERSION))" diff --git a/README.md b/README.md index a4e4340..1cf8d9f 100644 --- a/README.md +++ b/README.md @@ -7,11 +7,11 @@ English | [简体中文](README.zh-CN.md) [![Docs](https://img.shields.io/badge/docs-docs.deva.sh-111111)](https://docs.deva.sh) [![License](https://img.shields.io/github/license/thevibeworks/deva)](LICENSE) -Run Claude Code, Codex, Gemini, Grok, and Kimi inside Docker without pretending the agents' own permission prompts are the thing keeping you safe. +Run Claude Code, Codex, Gemini, Grok, Kimi, and opencode inside Docker without pretending the agents' own permission prompts are the thing keeping you safe. The container is the sandbox, mounts are the contract. What that buys you: -- Full-speed agents. Permission prompts exist because the blast radius is your host. Make the blast radius a container and YOLO stops being reckless — all five agents run with their permission systems off (`claude --dangerously-skip-permissions`, `codex --dangerously-bypass-approvals-and-sandbox`, `gemini --yolo`, `grok --always-approve`, `kimi --yolo`). Worst case dies with the container. +- Full-speed agents. Permission prompts exist because the blast radius is your host. Make the blast radius a container and YOLO stops being reckless — all six agents run with their permission systems off (`claude --dangerously-skip-permissions`, `codex --dangerously-bypass-approvals-and-sandbox`, `gemini --yolo`, `grok --always-approve`, `kimi --yolo`, opencode via `OPENCODE_PERMISSION` allow-all). Worst case dies with the container. - The vendor's code never sees your host. Agent CLIs are fast-moving npm trees with auto-updaters. Here they are born inside the image: no `~/.ssh`, no `~/.aws`, no shell env soup, no browser profiles. Nothing to harvest. - Everything that crosses the boundary is explicit. Files: mounts. Secrets: the env you pass. Network: the posture you pick — default bridge, your proxy, `--host-net`, or nothing. If you didn't wire it, the agent doesn't have it. - Identity is a launch flag, not a global singleton. Per-agent config homes under `~/.config/deva/`, `--auth-with` / `--config-home` for the second account or API-key billing. Switch accounts per run; project, sessions, and container state stay put. @@ -39,6 +39,7 @@ deva.sh codex # same container, different agent deva.sh gemini deva.sh grok deva.sh kimi +deva.sh opencode deva.sh claude --rm # throwaway container deva.sh claude --debug --dry-run # inspect the docker run before trusting it diff --git a/README.zh-CN.md b/README.zh-CN.md index f1c7c44..8758796 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -7,11 +7,11 @@ [![Docs](https://img.shields.io/badge/docs-docs.deva.sh-111111)](https://docs.deva.sh) [![License](https://img.shields.io/github/license/thevibeworks/deva)](LICENSE) -在 Docker 里跑 Claude Code、Codex、Gemini、Grok、Kimi 五个 agent CLI,并且不再假装 agent 自带的权限弹窗是在保护你。 +在 Docker 里跑 Claude Code、Codex、Gemini、Grok、Kimi、opencode 六个 agent CLI,并且不再假装 agent 自带的权限弹窗是在保护你。 容器即沙箱,挂载即契约。这换来的是: -- 全速跑 agent。权限弹窗存在的理由是爆炸半径等于你的整机;把爆炸半径缩小到一个容器,YOLO 就不再是鲁莽 —— 五个 agent 全部关掉自带权限系统跑(`claude --dangerously-skip-permissions`、`codex --dangerously-bypass-approvals-and-sandbox`、`gemini --yolo`、`grok --always-approve`、`kimi --yolo`)。最坏结果随容器一起销毁。 +- 全速跑 agent。权限弹窗存在的理由是爆炸半径等于你的整机;把爆炸半径缩小到一个容器,YOLO 就不再是鲁莽 —— 六个 agent 全部关掉自带权限系统跑(`claude --dangerously-skip-permissions`、`codex --dangerously-bypass-approvals-and-sandbox`、`gemini --yolo`、`grok --always-approve`、`kimi --yolo`、opencode 走 `OPENCODE_PERMISSION` 全放行)。最坏结果随容器一起销毁。 - 厂商代码永远见不到你的主机。agent CLI 是快速迭代、带自动更新的 npm 依赖树;在这里它们出生在镜像里:没有 `~/.ssh`、没有 `~/.aws`、没有 shell 环境变量汤、没有浏览器档案。想收集也无从下手。 - 跨越边界的一切都是显式的。文件靠挂载,密钥靠你传的 env,网络靠你选的姿态 —— 默认 bridge、走你的代理、`--host-net`、或者干脆不联网。你没接的线,agent 就没有。 - 身份是启动参数,不是全局单例。`~/.config/deva/` 下按 agent 分家,`--auth-with` / `--config-home` 切第二个账号或 API-key 计费。按次切账号,项目、会话、容器状态原地不动。 @@ -39,6 +39,7 @@ deva.sh codex # 同一个容器,换 agent deva.sh gemini deva.sh grok deva.sh kimi +deva.sh opencode deva.sh claude --rm # 一次性容器,用完即扔 deva.sh claude --debug --dry-run # 先看 docker run 长啥样再信它 diff --git a/agents/opencode.sh b/agents/opencode.sh new file mode 100644 index 0000000..2b2e78f --- /dev/null +++ b/agents/opencode.sh @@ -0,0 +1,85 @@ +# shellcheck shell=bash + +# shellcheck disable=SC1091 +if [ -f "$(dirname "${BASH_SOURCE[0]}")/shared_auth.sh" ]; then + source "$(dirname "${BASH_SOURCE[0]}")/shared_auth.sh" +fi + +agent_prepare() { + local -a args + if [ $# -gt 0 ]; then + args=("$@") + else + args=() + fi + AGENT_COMMAND=("opencode") + + parse_auth_args "opencode" "${args[@]+"${args[@]}"}" + AUTH_METHOD="$PARSED_AUTH_METHOD" + local -a remaining_args=("${PARSED_REMAINING_ARGS[@]+"${PARSED_REMAINING_ARGS[@]}"}") + + filter_trace_flag "${remaining_args[@]+"${remaining_args[@]}"}" + local use_trace="$TRACE_FLAG_PRESENT" + remaining_args=("${TRACE_FILTERED_ARGS[@]+"${TRACE_FILTERED_ARGS[@]}"}") + + if [ "$use_trace" = true ]; then + auth_error "--trace is not supported for opencode yet" \ + "cctrace has no opencode profile (thevibeworks/cctrace#89)" + fi + + # Container is the sandbox: opencode already allows everything inside the + # workspace; unlock the interactive asks (outside-workspace access, + # doom-loop guard, .env reads) that would stall an unattended run. + DOCKER_ARGS+=("-e" 'OPENCODE_PERMISSION={"doom_loop":"allow","external_directory":{"*":"allow"},"read":{"*":"allow"}}') + # The image pins the CLI version; the in-app updater must not fight it. + DOCKER_ARGS+=("-e" "OPENCODE_DISABLE_AUTOUPDATE=1") + + AGENT_COMMAND+=("${remaining_args[@]+"${remaining_args[@]}"}") + + setup_opencode_auth "$AUTH_METHOD" +} + +setup_opencode_auth() { + local method="$1" + + case "$method" in + oauth) + AUTH_DETAILS="oauth (~/.local/share/opencode)" + # opencode is XDG-native: auth.json (from `opencode auth login` / + # TUI /connect) lives in the data dir; config and state ride + # along so plugins and model prefs persist. Cache (models.json, + # self-update bin) stays container-local on purpose. + # Only mount host dirs directly when no config-home mechanism is + # active. -Q bare mode: no mounts at all. Explicit/auto + # config-home: centralized mount handles it. + if [ "${QUICK_MODE:-false}" = false ] && [ "${CONFIG_HOME_FROM_CLI:-false}" = false ] && [ "${CONFIG_HOME_AUTO:-false}" = false ]; then + local entry + while IFS= read -r entry; do + [ -n "$entry" ] || continue + if [ ! -d "$HOME/$entry" ]; then + echo "Warning: ~/$entry directory not found, creating it" >&2 + mkdir -p "$HOME/$entry" + fi + DOCKER_ARGS+=("-v" "$HOME/$entry:/home/deva/$entry") + done < <(agent_canonical_basenames "opencode") + fi + ;; + api-key) + # OPENCODE_API_KEY authenticates the opencode gateway provider + # (console.opencode.ai service-account key). No mount: a mounted + # data dir carries auth.json, which outranks the env key and + # could silently bill another account (same no-mount contract + # as grok/kimi api-key). + local key="${OPENCODE_API_KEY:-}" + if [ -z "$key" ]; then + auth_error "OPENCODE_API_KEY not set for --auth-with api-key" \ + "Set: export OPENCODE_API_KEY=your_key (from console.opencode.ai)" + fi + AUTH_DETAILS="api-key (OPENCODE_API_KEY)" + DOCKER_ARGS+=("-e" "OPENCODE_API_KEY=$key") + ;; + *) + auth_error "auth method '$method' not implemented for opencode" + ;; + esac +} diff --git a/agents/shared_auth.sh b/agents/shared_auth.sh index 5b0106f..a23a155 100644 --- a/agents/shared_auth.sh +++ b/agents/shared_auth.sh @@ -465,6 +465,9 @@ parse_auth_args() { kimi) supported_methods=(oauth api-key) ;; + opencode) + supported_methods=(oauth api-key) + ;; *) auth_error "Unknown agent: $agent_name" ;; @@ -532,6 +535,7 @@ parse_auth_args() { gemini) auth_method="oauth" ;; grok) auth_method="oauth" ;; kimi) auth_method="oauth" ;; + opencode) auth_method="oauth" ;; esac fi diff --git a/deva.sh b/deva.sh index 5b858b5..39067e1 100755 --- a/deva.sh +++ b/deva.sh @@ -110,7 +110,7 @@ _step() { usage() { cat <<'USAGE' -deva.sh - Docker-based multi-agent launcher (Claude, Codex, Gemini, Grok, Kimi) +deva.sh - Docker-based multi-agent launcher (Claude, Codex, Gemini, Grok, Kimi, opencode) Usage: deva.sh [deva flags] [agent] [-- agent-flags] @@ -215,6 +215,7 @@ Examples: deva.sh gemini # Launch gemini in the same default container shape deva.sh grok # Launch grok in the same default container shape deva.sh kimi # Launch kimi (oauth default; api-key via KIMI_CODE_API_KEY) + deva.sh opencode # Launch opencode (oauth default; api-key via OPENCODE_API_KEY) deva.sh claude --rm # Ephemeral: deva-work-myapp-claude-12345 # Container management (current project) @@ -943,7 +944,7 @@ generate_auth_tag() { fi case "$agent:$auth_method" in - claude:claude|codex:chatgpt|gemini:oauth|gemini:gemini-app-oauth|grok:oauth|kimi:oauth) + claude:claude|codex:chatgpt|gemini:oauth|gemini:gemini-app-oauth|grok:oauth|kimi:oauth|opencode:oauth) printf '%s' "auth-default" return ;; @@ -967,6 +968,7 @@ generate_auth_tag() { gemini) key_val="${GEMINI_API_KEY:-${GOOGLE_API_KEY:-}}" ;; grok) key_val="${XAI_API_KEY:-}" ;; kimi) key_val="${KIMI_CODE_API_KEY:-${KIMI_API_KEY:-}}" ;; + opencode) key_val="${OPENCODE_API_KEY:-}" ;; esac if [ -n "$key_val" ] && [ ${#key_val} -ge 4 ]; then printf '%s' "api-key-${key_val: -4}" @@ -1013,6 +1015,7 @@ agent_version_tag() { gemini) label="org.opencontainers.image.gemini_cli_version" ;; grok) label="org.opencontainers.image.grok_cli_version" ;; kimi) label="org.opencontainers.image.kimi_code_version" ;; + opencode) label="org.opencontainers.image.opencode_version" ;; esac local ver="" @@ -1358,7 +1361,9 @@ categorize_mount() { elif [[ "$dest" == /deva-host-chrome-bridge* ]]; then printf 'bridge' elif [[ "$dest" == /home/deva/.claude* ]] || [[ "$dest" == /home/deva/.codex* ]] || \ [[ "$dest" == /home/deva/.gemini* ]] || [[ "$dest" == /home/deva/.grok* ]] || \ - [[ "$dest" == /home/deva/.kimi-code* ]] || [ "$dest" = "/home/deva/.agents" ]; then + [[ "$dest" == /home/deva/.kimi-code* ]] || [[ "$dest" == /home/deva/.config/opencode* ]] || \ + [[ "$dest" == /home/deva/.local/share/opencode* ]] || [[ "$dest" == /home/deva/.local/state/opencode* ]] || \ + [ "$dest" = "/home/deva/.agents" ]; then printf 'config' else printf 'user' fi @@ -1741,10 +1746,29 @@ cmd_status() { if [ -d "$config_root" ]; then echo "Agent Homes ($(shorten_path "$config_root")):" - for agent_name in claude codex gemini grok kimi; do + for agent_name in claude codex gemini grok kimi opencode; do local agent_dir="$config_root/$agent_name" if [ -d "$agent_dir" ]; then local canonical="" other_count=0 entry is_canonical + if [ "$agent_name" = "opencode" ]; then + # XDG-native agent: canonical entries are nested, a + # top-level ls only shows their .config/.local containers. + while IFS= read -r entry; do + [ -n "$entry" ] || continue + if [ -L "$agent_dir/$entry" ]; then + canonical="${canonical} ${entry}@" + elif [ -e "$agent_dir/$entry" ]; then + canonical="${canonical} ${entry}" + fi + done < <(agent_canonical_basenames "$agent_name") + while IFS= read -r entry; do + [ -n "$entry" ] || continue + case "$entry" in + .config | .local) ;; + *) other_count=$((other_count + 1)) ;; + esac + done < <(ls -1A "$agent_dir" 2>/dev/null) + else while IFS= read -r entry; do [ -n "$entry" ] || continue is_canonical=false @@ -1765,6 +1789,7 @@ cmd_status() { other_count=$((other_count + 1)) fi done < <(ls -1A "$agent_dir" 2>/dev/null) + fi local line="${canonical:- (no canonical entries)}" [ "$other_count" -gt 0 ] && line="${line} (+${other_count} other)" printf ' %-10s%s\n' "$agent_name" "$line" @@ -2232,6 +2257,16 @@ should_skip_env_for_auth() { ;; esac ;; + opencode) + # OPENCODE_API_KEY only travels when deva injects it (api-key mode). + # A host-set key leaking into oauth mode would enable the gateway + # provider beside the subscription and silently bill the key. + case "$name" in + OPENCODE_API_KEY) + return 0 + ;; + esac + ;; esac return 1 @@ -2290,6 +2325,11 @@ agent_canonical_basenames() { gemini) printf '%s\n' '.gemini' ;; grok) printf '%s\n' '.grok' ;; kimi) printf '%s\n' '.kimi-code' ;; + # opencode is XDG-native: canonical entries are nested paths, not + # top-level dotfiles. mount_agent_canonical handles nested targets + # verbatim (docker creates intermediate dirs); cache is left out on + # purpose (disposable models.json + self-update bin). + opencode) printf '%s\n' '.config/opencode' '.local/share/opencode' '.local/state/opencode' ;; *) return 0 ;; esac } @@ -2311,6 +2351,13 @@ kimi_api_key_no_mount() { [ "$ACTIVE_AGENT" = "kimi" ] && [ "${AUTH_METHOD:-}" = "api-key" ] } +# opencode api-key contract: OPENCODE_API_KEY travels as env only. A mounted +# data dir carries auth.json, which outranks the env key for the gateway +# provider and could silently bill another account, so api-key mounts nothing. +opencode_api_key_no_mount() { + [ "$ACTIVE_AGENT" = "opencode" ] && [ "${AUTH_METHOD:-}" = "api-key" ] +} + # grok's self-updater writes Linux binaries into ~/.grok/bin and # ~/.grok/downloads — inside the auth dir we bind-mount. Verified against # @xai-official/grok 0.2.93: a mounted config.toml without the npm installer @@ -2348,6 +2395,9 @@ mount_agent_canonical() { if [ "$agent" = "kimi" ] && kimi_api_key_no_mount; then return 0 fi + if [ "$agent" = "opencode" ] && opencode_api_key_no_mount; then + return 0 + fi local entry src while IFS= read -r entry; do @@ -2404,7 +2454,7 @@ has_auth_override() { # Non-default --auth-with if [ -n "${AUTH_METHOD:-}" ]; then case "${ACTIVE_AGENT}:${AUTH_METHOD}" in - claude:claude|codex:chatgpt|gemini:oauth|gemini:gemini-app-oauth|grok:oauth|kimi:oauth) ;; + claude:claude|codex:chatgpt|gemini:oauth|gemini:gemini-app-oauth|grok:oauth|kimi:oauth|opencode:oauth) ;; *) return 0 ;; esac fi @@ -2417,6 +2467,7 @@ has_auth_override() { gemini) auth_vars="GEMINI_API_KEY" ;; grok) auth_vars="XAI_API_KEY" ;; kimi) auth_vars="KIMI_CODE_API_KEY KIMI_API_KEY" ;; + opencode) auth_vars="OPENCODE_API_KEY" ;; esac local var @@ -2463,6 +2514,12 @@ default_credential_target_path() { # a session token over XAI_API_KEY, so blank-overlay auth.json anyway. printf '%s' "/home/deva/.grok/auth.json" ;; + opencode) + # api-key mode mounts no data dir (opencode_api_key_no_mount), but an + # explicit user -v/.deva VOLUME can still carry one in; auth.json + # outranks OPENCODE_API_KEY, so blank-overlay it anyway. + printf '%s' "/home/deva/.local/share/opencode/auth.json" + ;; *) return 1 ;; @@ -3802,7 +3859,7 @@ if [ "$CONFIG_HOME_AUTO" = true ]; then fi if [ "$CONFIG_HOME_FROM_CLI" = true ] && [ -n "$CONFIG_HOME" ]; then - if [ -d "$CONFIG_HOME/claude" ] || [ -d "$CONFIG_HOME/codex" ] || [ -d "$CONFIG_HOME/gemini" ] || [ -d "$CONFIG_HOME/grok" ] || [ -d "$CONFIG_HOME/kimi" ]; then + if [ -d "$CONFIG_HOME/claude" ] || [ -d "$CONFIG_HOME/codex" ] || [ -d "$CONFIG_HOME/gemini" ] || [ -d "$CONFIG_HOME/grok" ] || [ -d "$CONFIG_HOME/kimi" ] || [ -d "$CONFIG_HOME/opencode" ]; then CONFIG_ROOT="$CONFIG_HOME" CONFIG_HOME="" CONFIG_HOME_AUTO=false @@ -3875,6 +3932,22 @@ autolink_legacy_into_deva_root() { if [ -d "$CONFIG_ROOT" ]; then [ -d "$CONFIG_ROOT/kimi/.kimi-code" ] || [ -L "$CONFIG_ROOT/kimi/.kimi-code" ] || mkdir -p "$CONFIG_ROOT/kimi/.kimi-code" fi + + # opencode: XDG-native, so the legacy homes and the links are nested. + local _oc_entry + while IFS= read -r _oc_entry; do + [ -n "$_oc_entry" ] || continue + if [ -d "$HOME/$_oc_entry" ]; then + mkdir -p "$CONFIG_ROOT/opencode/$(dirname "$_oc_entry")" + if [ ! -e "$CONFIG_ROOT/opencode/$_oc_entry" ] && [ ! -L "$CONFIG_ROOT/opencode/$_oc_entry" ]; then + ln -s "$HOME/$_oc_entry" "$CONFIG_ROOT/opencode/$_oc_entry" + echo "autolink: ~/$_oc_entry -> $CONFIG_ROOT/opencode/$_oc_entry" >&2 + fi + fi + if [ -d "$CONFIG_ROOT" ]; then + [ -d "$CONFIG_ROOT/opencode/$_oc_entry" ] || [ -L "$CONFIG_ROOT/opencode/$_oc_entry" ] || mkdir -p "$CONFIG_ROOT/opencode/$_oc_entry" + fi + done < <(agent_canonical_basenames "opencode") } check_agent "$ACTIVE_AGENT" @@ -3905,6 +3978,13 @@ if [ -n "$CONFIG_HOME" ] && [ "$DRY_RUN" != true ]; then kimi) [ -d "$CONFIG_HOME/.kimi-code" ] || mkdir -p "$CONFIG_HOME/.kimi-code" ;; + opencode) + while IFS= read -r _oc_entry; do + [ -n "$_oc_entry" ] || continue + [ -d "$CONFIG_HOME/$_oc_entry" ] || mkdir -p "$CONFIG_HOME/$_oc_entry" + done < <(agent_canonical_basenames "opencode") + unset _oc_entry + ;; esac fi @@ -3950,6 +4030,12 @@ if [ "$CONFIG_HOME_FROM_CLI" = true ] && [ -n "$CONFIG_HOME" ] && [ "$_config_ho echo "warning: $CONFIG_HOME/.kimi-code is empty; authentication will need to be set up" >&2 fi ;; + opencode) + # auth.json lives in the data dir; that's the one that matters. + if [ ! -d "$CONFIG_HOME/.local/share/opencode" ] || [ -z "$(ls -A "$CONFIG_HOME/.local/share/opencode" 2>/dev/null)" ]; then + echo "warning: $CONFIG_HOME/.local/share/opencode is empty; authentication will need to be set up" >&2 + fi + ;; esac fi @@ -3988,7 +4074,7 @@ _step "agent_prepare" if [ -n "${AUTH_METHOD:-}" ]; then case "${ACTIVE_AGENT}:${AUTH_METHOD}" in - claude:claude|codex:chatgpt|gemini:oauth|gemini:gemini-app-oauth|grok:oauth|kimi:oauth) ;; + claude:claude|codex:chatgpt|gemini:oauth|gemini:gemini-app-oauth|grok:oauth|kimi:oauth|opencode:oauth) ;; *) _needs_rewrite=true ;; esac @@ -4107,6 +4193,14 @@ else if [ -d "$HOME/.kimi-code" ] && ! kimi_api_key_no_mount; then DOCKER_ARGS+=("-v" "$HOME/.kimi-code:/home/deva/.kimi-code") fi + # opencode api-key uses OPENCODE_API_KEY env (no mount); oauth wants the XDG trio. + if ! opencode_api_key_no_mount; then + while IFS= read -r _oc_entry; do + [ -n "$_oc_entry" ] || continue + [ -d "$HOME/$_oc_entry" ] && DOCKER_ARGS+=("-v" "$HOME/$_oc_entry:/home/deva/$_oc_entry") + done < <(agent_canonical_basenames "opencode") + unset _oc_entry + fi fi fi _step "mount dispatch: done" diff --git a/docs/advanced-usage.md b/docs/advanced-usage.md index a2f0fa9..59c564b 100644 --- a/docs/advanced-usage.md +++ b/docs/advanced-usage.md @@ -49,7 +49,8 @@ Deva-root layout: ├── codex/ ├── gemini/ ├── grok/ -└── kimi/ +├── kimi/ +└── opencode/ ``` ```bash @@ -187,7 +188,9 @@ verbatim and is never intercepted by deva. which records every API call the agent makes — messages, OAuth, usage/credits, MCP — not just the chat endpoint. Everything else on the line goes to the agent unchanged. Codex, Grok, and Kimi use cctrace client profiles -(cctrace >= 0.11) and always run MITM capture. +(cctrace >= 0.11) and always run MITM capture. opencode has no cctrace +profile yet, so `deva.sh opencode --trace` is rejected +(thevibeworks/cctrace#89). When tracing is on, the entrypoint installs the cctrace MITM CA into the container's system trust store (`update-ca-certificates`) so subprocesses and diff --git a/docs/authentication.md b/docs/authentication.md index d4ee6ef..0e4a784 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -22,6 +22,7 @@ This guide documents what `deva.sh` actually supports, what env vars it reads, a | Gemini | `oauth` | `api-key`, `gemini-api-key`, `vertex`, `compute-adc`, `gemini-app-oauth`, credentials file | `.gemini`, `GEMINI_API_KEY`, gcloud, service-account JSON | | Grok | `oauth` | `api-key` | `.grok/auth.json`, `XAI_API_KEY` | | Kimi | `oauth` | `api-key` | `.kimi-code` (device-code), `KIMI_CODE_API_KEY` -> `KIMI_MODEL_*` | +| opencode | `oauth` | `api-key` | `.local/share/opencode/auth.json` (device-code), `OPENCODE_API_KEY` | ## Claude @@ -411,6 +412,51 @@ self-update trampoline, no platform binary), so there is no host-mount shadowing to guard against. The image pin (`KIMI_CODE_VERSION`) is the only version that matters. +## opencode + +### Default: `--auth-with oauth` + +Mounts (opencode is XDG-native — three dirs instead of one dot-dir): + +- `/home/deva/.config/opencode` (config, plugins) +- `/home/deva/.local/share/opencode` (auth.json, session db, logs) +- `/home/deva/.local/state/opencode` (model prefs, prompt history) + +`~/.cache/opencode` stays container-local on purpose: it only holds the +models.json cache and the self-updater's bin dir, and the image pins the +CLI version (`OPENCODE_DISABLE_AUTOUPDATE=1` is set in the container). + +First login has no browser, so use the device-code flow inside the +container: run `opencode auth login` (or `/connect` in the TUI), open the +printed URL on any device. Or authenticate on the host once; deva +auto-links the three XDG dirs and the mount carries `auth.json` in. + +opencode's permission model already allows everything inside the workspace; +deva additionally unlocks the interactive asks (outside-workspace access, +doom-loop guard, `.env` reads) via `OPENCODE_PERMISSION` — the container is +the sandbox. + +### `--auth-with api-key` + +Inputs: + +- `OPENCODE_API_KEY` (service-account key from [console.opencode.ai](https://console.opencode.ai)) + +The key travels as env only and authenticates the opencode gateway +provider. This mode mounts none of the XDG dirs: a mounted `auth.json` +outranks the env key and could silently bill another account (same +no-mount contract as grok/kimi api-key). A blank overlay hides `auth.json` +even if a user `-v` carries a data dir in. + +```bash +export OPENCODE_API_KEY=sk-... +deva.sh opencode --auth-with api-key +``` + +BYO provider keys (Anthropic, OpenAI, OpenRouter, ...) are opencode config, +not deva auth methods — wire them with `-e` / `.deva` `ENV=` entries and +opencode's own `opencode.jsonc`. + ## Config Homes And Auth Isolation Default homes live under: @@ -421,6 +467,7 @@ Default homes live under: ~/.config/deva/gemini ~/.config/deva/grok ~/.config/deva/kimi +~/.config/deva/opencode ``` Use `--config-home` when you want a separate identity: diff --git a/docs/how-it-works.md b/docs/how-it-works.md index f13a524..fee5cda 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -63,13 +63,14 @@ Default per-agent homes live under: ├── codex/ ├── gemini/ ├── grok/ -└── kimi/ +├── kimi/ +└── opencode/ ``` `--config-home` supports two layouts: -- leaf home: `DIR/.claude`, `DIR/.claude.json`, `DIR/.codex`, `DIR/.gemini`, `DIR/.grok`, `DIR/.kimi-code` -- deva root: `DIR/claude`, `DIR/codex`, `DIR/gemini`, `DIR/grok`, `DIR/kimi` +- leaf home: `DIR/.claude`, `DIR/.claude.json`, `DIR/.codex`, `DIR/.gemini`, `DIR/.grok`, `DIR/.kimi-code`, or opencode's nested `DIR/.config/opencode` + `DIR/.local/share/opencode` + `DIR/.local/state/opencode` +- deva root: `DIR/claude`, `DIR/codex`, `DIR/gemini`, `DIR/grok`, `DIR/kimi`, `DIR/opencode` `-Q` disables config-home resolution, autolink, and host config mounts entirely. @@ -85,6 +86,7 @@ Examples: - Gemini default: `.gemini` - Grok default: `.grok/auth.json` - Kimi default: `.kimi-code` (device-code OAuth); api-key maps `KIMI_CODE_API_KEY` onto `KIMI_MODEL_*` +- opencode default: the XDG trio with `auth.json` under `.local/share/opencode` (device-code OAuth); api-key passes `OPENCODE_API_KEY`, mounts nothing When non-default auth is active, deva mounts a blank overlay over the default credential file path so the agent cannot silently fall back to some unrelated OAuth state. That is the point of the overlay fix. @@ -116,7 +118,7 @@ Persistent is default: - one default container shape per project - reused across runs -- same workspace can run Claude, Codex, Gemini, Grok, and Kimi in the same container when mounts, config, and auth line up +- same workspace can run Claude, Codex, Gemini, Grok, Kimi, and opencode in the same container when mounts, config, and auth line up - different volumes, explicit config homes, or auth modes create separate persistent containers Ephemeral with `--rm`: diff --git a/docs/index.md b/docs/index.md index 91498ea..9f4c8c6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # deva.sh -Run Codex, Claude Code, Gemini, Grok, and Kimi inside Docker without pretending +Run Codex, Claude Code, Gemini, Grok, Kimi, and opencode inside Docker without pretending the agent's own sandbox is the thing keeping you safe. The container is the sandbox. Explicit mounts are the contract. @@ -24,7 +24,7 @@ If you want the internals instead of vague hand-waving: ## What This Is -- a Docker-based launcher for Codex, Claude, Gemini, Grok, and Kimi +- a Docker-based launcher for Codex, Claude, Gemini, Grok, Kimi, and opencode - one warm default container shape per project by default - explicit mount and env wiring instead of mystery behavior - per-agent config homes under `~/.config/deva/` diff --git a/docs/philosophy.md b/docs/philosophy.md index 2a808bf..9370b4a 100644 --- a/docs/philosophy.md +++ b/docs/philosophy.md @@ -39,7 +39,7 @@ Persistent per-project containers mean: - warm package caches - stateful shell history and scratch space -- fast switching between Claude, Codex, Gemini, Grok, and Kimi +- fast switching between Claude, Codex, Gemini, Grok, Kimi, and opencode `--rm` still exists. It just is not the default because the default should serve real work instead of screenshots. diff --git a/docs/quick-start.md b/docs/quick-start.md index 3b66a1e..dbf580f 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -28,6 +28,7 @@ That installs: - `agents/gemini.sh` - `agents/grok.sh` - `agents/kimi.sh` +- `agents/opencode.sh` - `agents/shared_auth.sh` It also pulls `ghcr.io/thevibeworks/deva:latest`, with Docker Hub as fallback. @@ -77,6 +78,7 @@ deva.sh claude deva.sh gemini deva.sh grok deva.sh kimi +deva.sh opencode ``` That is one of the main reasons this wrapper exists. You do not need a separate pet workflow for every vendor. @@ -129,6 +131,13 @@ export KIMI_CODE_API_KEY=sk-... deva.sh kimi --auth-with api-key ``` +opencode with a gateway service-account key: + +```bash +export OPENCODE_API_KEY=sk-... +deva.sh opencode --auth-with api-key +``` + More auth details live in [Authentication Guide](authentication.md). ## Useful Modes diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index d0d4883..0a565b1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -84,7 +84,7 @@ If the dry-run shape is correct but the agent still cannot authenticate, the wra Symptom: -- first run warns that `.claude`, `.codex`, `.gemini`, `.grok`, or `.kimi-code` is empty +- first run warns that `.claude`, `.codex`, `.gemini`, `.grok`, `.kimi-code`, or `.local/share/opencode` is empty Meaning: diff --git a/install.sh b/install.sh index 6c02fcc..d5def3d 100644 --- a/install.sh +++ b/install.sh @@ -12,6 +12,7 @@ agent_files=( "gemini.sh" "grok.sh" "kimi.sh" + "opencode.sh" "shared_auth.sh" ) @@ -142,6 +143,7 @@ echo " - $INSTALL_DIR/agents/codex.sh" echo " - $INSTALL_DIR/agents/gemini.sh" echo " - $INSTALL_DIR/agents/grok.sh" echo " - $INSTALL_DIR/agents/kimi.sh" +echo " - $INSTALL_DIR/agents/opencode.sh" echo " - $INSTALL_DIR/agents/shared_auth.sh" echo "" echo "Quick start:" @@ -153,6 +155,7 @@ echo " deva.sh claude -- --help" echo " deva.sh gemini -- --help" echo " deva.sh grok -- --help" echo " deva.sh kimi -- --help" +echo " deva.sh opencode -- --help" echo " deva.sh shell" echo "" echo "warning: do not point deva at your real home directory with dangerous permissions enabled" diff --git a/llms.txt b/llms.txt index 749a0b2..0e0aefc 100644 --- a/llms.txt +++ b/llms.txt @@ -1,8 +1,8 @@ # deva.sh -> Docker-first launcher for AI coding agents — Claude Code, Codex, Gemini, Grok, and Kimi. +> Docker-first launcher for AI coding agents — Claude Code, Codex, Gemini, Grok, Kimi, and opencode. > The container is the sandbox, mounts are the explicit contract, and one warm -> project container serves all five agents. A bash script, not a framework. MIT. +> project container serves all six agents. A bash script, not a framework. MIT. deva.sh is a single entry point installed on the host; agents run inside `ghcr.io/thevibeworks/deva` images (`:latest`, `:rust`, `:cloak` for stable, @@ -10,7 +10,8 @@ deva.sh is a single entry point installed on the host; agents run inside are disabled by design — isolation comes from Docker, not permission theater: `claude --dangerously-skip-permissions`, `codex --dangerously-bypass-approvals-and-sandbox`, -`gemini --yolo`, `grok --always-approve`, and `kimi --yolo`. Auth +`gemini --yolo`, `grok --always-approve`, `kimi --yolo`, and opencode via +`OPENCODE_PERMISSION` allow-all. Auth lives in per-agent config homes under `~/.config/deva/` with OAuth (default) or API-key modes per agent, switchable per run (`--auth-with`, `--config-home`) so accounts and billing modes swap without touching project or session state; @@ -30,6 +31,7 @@ deva.sh codex # same container shape, other agents deva.sh gemini deva.sh grok deva.sh kimi +deva.sh opencode deva.sh claude -p cloak # stealth-browser image (CloakBrowser, headed Xvfb) deva.sh claude --debug --dry-run # inspect the docker run before trusting it ``` diff --git a/scripts/install-agent-tooling.sh b/scripts/install-agent-tooling.sh index 998bd34..34978bb 100644 --- a/scripts/install-agent-tooling.sh +++ b/scripts/install-agent-tooling.sh @@ -9,6 +9,7 @@ set -euo pipefail : "${GEMINI_CLI_VERSION:?GEMINI_CLI_VERSION is required}" : "${GROK_CLI_VERSION:?GROK_CLI_VERSION is required}" : "${KIMI_CODE_VERSION:?KIMI_CODE_VERSION is required}" +: "${OPENCODE_VERSION:?OPENCODE_VERSION is required}" CCTRACE_VERSION="${CCTRACE_VERSION:-0.4.0}" CCX_VERSION="${CCX_VERSION:-v0.7.0}" @@ -138,9 +139,14 @@ install_npm_agent_tooling() { log "Installing npm agent tooling" log "Proxy config:" log_proxy_config - log "Requested versions: claude=${CLAUDE_CODE_VERSION} codex=${CODEX_VERSION} gemini=${GEMINI_CLI_VERSION} grok=${GROK_CLI_VERSION} kimi=${KIMI_CODE_VERSION}" + log "Requested versions: claude=${CLAUDE_CODE_VERSION} codex=${CODEX_VERSION} gemini=${GEMINI_CLI_VERSION} grok=${GROK_CLI_VERSION} kimi=${KIMI_CODE_VERSION} opencode=${OPENCODE_VERSION}" mkdir -p "$DEVA_HOME/.npm-global" "$DEVA_HOME/.local/bin" + # opencode is XDG-native; pre-create its dirs as the deva user so docker + # never root-creates them as bind-mount-point parents at container create. + mkdir -p "$DEVA_HOME/.config/opencode" \ + "$DEVA_HOME/.local/share/opencode" \ + "$DEVA_HOME/.local/state/opencode" npm config set prefix "$DEVA_HOME/.npm-global" check_npm_registry_dns @@ -151,6 +157,7 @@ install_npm_agent_tooling() { "@google/gemini-cli@${GEMINI_CLI_VERSION}" \ "@xai-official/grok@${GROK_CLI_VERSION}" \ "@moonshot-ai/kimi-code@${KIMI_CODE_VERSION}" \ + "opencode-ai@${OPENCODE_VERSION}" \ || die "npm install failed" npm cache clean --force @@ -163,7 +170,10 @@ install_npm_agent_tooling() { # kimi's npm bin is a plain symlink to dist/main.mjs (no self-update # trampoline, no platform binary), so it needs no pinning — just verify. "$DEVA_HOME/.npm-global/bin/kimi" --version - (npm list -g --depth=0 @anthropic-ai/claude-code @openai/codex @google/gemini-cli @xai-official/grok @moonshot-ai/kimi-code || true) + # opencode's npm bin is a node shim that execs the platform package + # binary from node_modules (no self-update dir shadowing) — just verify. + "$DEVA_HOME/.npm-global/bin/opencode" --version + (npm list -g --depth=0 @anthropic-ai/claude-code @openai/codex @google/gemini-cli @xai-official/grok @moonshot-ai/kimi-code opencode-ai || true) } # grok's npm postinstall puts the real binary in ~/.grok/bin (the CLI's diff --git a/scripts/release-utils.sh b/scripts/release-utils.sh index d738f7f..074136e 100755 --- a/scripts/release-utils.sh +++ b/scripts/release-utils.sh @@ -31,6 +31,7 @@ TOOL_REGISTRY=( "gemini-cli|npm|@google/gemini-cli|org.opencontainers.image.gemini_cli_version|https://www.npmjs.com/package/@google/gemini-cli||agent|main" "grok-cli|npm|@xai-official/grok|org.opencontainers.image.grok_cli_version|https://www.npmjs.com/package/@xai-official/grok||agent|main" "kimi-code|npm|@moonshot-ai/kimi-code|org.opencontainers.image.kimi_code_version|https://www.npmjs.com/package/@moonshot-ai/kimi-code||agent|main" + "opencode|npm|opencode-ai|org.opencontainers.image.opencode_version|https://www.npmjs.com/package/opencode-ai|github:anomalyco/opencode|agent|main" "ccx|github-release|thevibeworks/ccx|org.opencontainers.image.ccx_version|https://github.com/thevibeworks/ccx|github:thevibeworks/ccx|agent|main" "copilot-api|github-commit|ericc-ch/copilot-api|org.opencontainers.image.copilot_api_version|https://github.com/ericc-ch/copilot-api||agent|main" "cctrace|npm|@thevibeworks/cctrace|org.opencontainers.image.cctrace_version|https://www.npmjs.com/package/@thevibeworks/cctrace|github:thevibeworks/cctrace|agent|main" diff --git a/scripts/resolve-tool-versions.sh b/scripts/resolve-tool-versions.sh index 948274e..35ecba5 100644 --- a/scripts/resolve-tool-versions.sh +++ b/scripts/resolve-tool-versions.sh @@ -32,6 +32,7 @@ main() { resolve_tool "gemini_cli_version" "gemini-cli" resolve_tool "grok_cli_version" "grok-cli" resolve_tool "kimi_code_version" "kimi-code" + resolve_tool "opencode_version" "opencode" resolve_tool "ccx_version" "ccx" resolve_tool "copilot_api_version" "copilot-api" } diff --git a/scripts/test-install-agent-tooling.sh b/scripts/test-install-agent-tooling.sh index 59f3c67..59cc473 100755 --- a/scripts/test-install-agent-tooling.sh +++ b/scripts/test-install-agent-tooling.sh @@ -34,7 +34,7 @@ config) ;; install) mkdir -p "$DEVA_HOME/.npm-global/bin" - for bin in claude codex gemini grok kimi; do + for bin in claude codex gemini grok kimi opencode; do cat >"$DEVA_HOME/.npm-global/bin/$bin" <<'BIN' #!/usr/bin/env bash case "$(basename "$0")" in @@ -43,6 +43,7 @@ case "$(basename "$0")" in gemini) echo "__GEMINI_CLI_VERSION__" ;; grok) echo "grok __GROK_CLI_VERSION__" ;; kimi) echo "__KIMI_CODE_VERSION__" ;; + opencode) echo "__OPENCODE_VERSION__" ;; esac BIN chmod +x "$DEVA_HOME/.npm-global/bin/$bin" @@ -86,6 +87,7 @@ sed -i \ -e "s#__GEMINI_CLI_VERSION__#$GEMINI_CLI_VERSION#g" \ -e "s#__GROK_CLI_VERSION__#$GROK_CLI_VERSION#g" \ -e "s#__KIMI_CODE_VERSION__#$KIMI_CODE_VERSION#g" \ + -e "s#__OPENCODE_VERSION__#$OPENCODE_VERSION#g" \ "$fake_bin/npm" cat >"$fake_bin/curl" <<'EOF' diff --git a/scripts/test-opencode-auth.sh b/scripts/test-opencode-auth.sh new file mode 100755 index 0000000..bb6918a --- /dev/null +++ b/scripts/test-opencode-auth.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# test-opencode-auth.sh - opencode agent auth wiring (XDG-trio mounts + api-key env) +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +tmp_home="$(mktemp -d)" +cleanup() { rm -rf "$tmp_home"; } +trap cleanup EXIT + +fail=0 +run_dry() { + ( + cd "$REPO_ROOT" + HOME="$tmp_home" \ + XDG_CONFIG_HOME="$tmp_home/.config" \ + XDG_CACHE_HOME="$tmp_home/.cache" \ + DEVA_NO_DOCKER=1 \ + ./deva.sh "$@" + ) 2>&1 +} + +want() { + local desc="$1" needle="$2" hay="$3" + if grep -F -- "$needle" <<<"$hay" >/dev/null; then + echo " PASS $desc" + else + echo " FAIL $desc" + echo " expected to find: $needle" >&2 + fail=1 + fi +} +want_absent() { + local desc="$1" needle="$2" hay="$3" + if grep -F -- "$needle" <<<"$hay" >/dev/null; then + echo " FAIL $desc" + echo " expected absent: $needle" >&2 + fail=1 + else + echo " PASS $desc" + fi +} + +echo "=== opencode oauth (default) ===" +oauth_out="$(run_dry opencode --debug --dry-run || true)" +want "runs opencode" "opencode" "$oauth_out" +want "auth method is oauth" "DEVA_AUTH_METHOD=oauth" "$oauth_out" +want "sandbox permission override" 'OPENCODE_PERMISSION={"doom_loop":"allow"' "$oauth_out" +want "autoupdate disabled" "OPENCODE_DISABLE_AUTOUPDATE=1" "$oauth_out" + +echo "=== opencode oauth: hybrid config-root mounts the XDG trio ===" +# Seed the config-root layout an autolinked oauth run leaves behind and +# assert the centralized walk (mount_agent_canonical) emits nested mounts. +mkdir -p "$tmp_home/.config/deva/opencode/.config/opencode" \ + "$tmp_home/.config/deva/opencode/.local/share/opencode" \ + "$tmp_home/.config/deva/opencode/.local/state/opencode" +hybrid_oauth_out="$(run_dry opencode --dry-run || true)" +want "config dir mounted" ":/home/deva/.config/opencode" "$hybrid_oauth_out" +want "data dir mounted" ":/home/deva/.local/share/opencode" "$hybrid_oauth_out" +want "state dir mounted" ":/home/deva/.local/state/opencode" "$hybrid_oauth_out" +want_absent "cache dir never mounted" ":/home/deva/.cache/opencode" "$hybrid_oauth_out" + +echo "=== opencode api-key: OPENCODE_API_KEY env, no mount ===" +apikey_out="$(OPENCODE_API_KEY=sk-test-opencode-1234 run_dry opencode --auth-with api-key --dry-run -- run hi || true)" +want "api key wired + redacted" "OPENCODE_API_KEY=" "$apikey_out" +want "key last-4 tags container" "--api-key-1234--" "$apikey_out" +want "passes agent args after --" "opencode run hi" "$apikey_out" +want_absent "no config dir mount in api-key mode" ":/home/deva/.config/opencode" "$apikey_out" +want_absent "no state dir mount in api-key mode" ":/home/deva/.local/state/opencode" "$apikey_out" +# The data dir itself must not ride in; the blank overlay at auth.json is +# expected (a user -v could still carry a data dir with auth.json in it). +want "auth.json blank-overlayed" ".blank:/home/deva/.local/share/opencode/auth.json" "$apikey_out" + +echo "=== opencode api-key: no mount on the hybrid config-root path either ===" +hybrid_apikey_out="$(OPENCODE_API_KEY=sk-test-opencode-1234 run_dry opencode --auth-with api-key --dry-run || true)" +want_absent "hybrid layout: no config dir mount in api-key mode" ":/home/deva/.config/opencode" "$hybrid_apikey_out" +rm -rf "$tmp_home/.config/deva/opencode" + +echo "=== opencode oauth: host OPENCODE_API_KEY must not leak ===" +leak_out="$(OPENCODE_API_KEY=sk-host-leak-9999 run_dry opencode -e OPENCODE_API_KEY --dry-run || true)" +want_absent "host key filtered in oauth mode" "OPENCODE_API_KEY" "$leak_out" + +echo "=== opencode --trace: rejected until cctrace ships a profile ===" +trace_out="$(run_dry opencode --trace --dry-run || true)" +want "trace rejected" "--trace is not supported for opencode" "$trace_out" +want "points at cctrace issue" "thevibeworks/cctrace#89" "$trace_out" + +echo "=== opencode --trace after -- is passthrough ===" +trace_pass_out="$(run_dry opencode --dry-run -- --trace || true)" +want_absent "--trace after -- not absorbed" "--trace is not supported" "$trace_pass_out" +want "--trace passed to agent" "opencode --trace" "$trace_pass_out" + +echo "=== opencode api-key: missing key errors ===" +missing_out="$(OPENCODE_API_KEY= run_dry opencode --auth-with api-key --dry-run || true)" +want "errors when key unset" "OPENCODE_API_KEY not set" "$missing_out" + +if [ "$fail" -ne 0 ]; then + echo "FAIL: opencode auth wiring" >&2 + exit 1 +fi +echo "OK: opencode auth wiring" diff --git a/scripts/update-version-pins.sh b/scripts/update-version-pins.sh index 685df2a..bf5b0a1 100755 --- a/scripts/update-version-pins.sh +++ b/scripts/update-version-pins.sh @@ -132,6 +132,7 @@ registry_tool() { GEMINI_CLI_VERSION) echo "gemini-cli" ;; GROK_CLI_VERSION) echo "grok-cli" ;; KIMI_CODE_VERSION) echo "kimi-code" ;; + OPENCODE_VERSION) echo "opencode" ;; CCX_VERSION) echo "ccx" ;; COPILOT_API_VERSION) echo "copilot-api" ;; PLAYWRIGHT_VERSION) echo "playwright" ;; @@ -227,6 +228,7 @@ main() { pin "Gemini CLI" GEMINI_CLI_VERSION npm "@google/gemini-cli" pin "Grok CLI" GROK_CLI_VERSION npm "@xai-official/grok" pin "Kimi Code" KIMI_CODE_VERSION npm "@moonshot-ai/kimi-code" + pin "opencode" OPENCODE_VERSION npm "opencode-ai" pin "CCX" CCX_VERSION git-tag "https://github.com/thevibeworks/ccx.git" pin "Copilot API" COPILOT_API_VERSION git-commit "https://github.com/ericc-ch/copilot-api.git" "refs/heads/master" diff --git a/scripts/version-pins.sh b/scripts/version-pins.sh index 00c70b9..1f0ea6d 100644 --- a/scripts/version-pins.sh +++ b/scripts/version-pins.sh @@ -19,6 +19,7 @@ VERSION_PIN_VARS=( GEMINI_CLI_VERSION GROK_CLI_VERSION KIMI_CODE_VERSION + OPENCODE_VERSION CCX_VERSION COPILOT_API_VERSION PLAYWRIGHT_VERSION @@ -84,6 +85,7 @@ CODEX_VERSION=$CODEX_VERSION GEMINI_CLI_VERSION=$GEMINI_CLI_VERSION GROK_CLI_VERSION=$GROK_CLI_VERSION KIMI_CODE_VERSION=$KIMI_CODE_VERSION +OPENCODE_VERSION=$OPENCODE_VERSION CCX_VERSION=$CCX_VERSION COPILOT_API_VERSION=$COPILOT_API_VERSION PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION diff --git a/scripts/version-upgrade.sh b/scripts/version-upgrade.sh index f638fe7..87ffa3e 100755 --- a/scripts/version-upgrade.sh +++ b/scripts/version-upgrade.sh @@ -18,6 +18,7 @@ _CLI_CODEX="${CODEX_VERSION:-}" _CLI_GEMINI="${GEMINI_CLI_VERSION:-}" _CLI_GROK="${GROK_CLI_VERSION:-}" _CLI_KIMI="${KIMI_CODE_VERSION:-}" +_CLI_OPENCODE="${OPENCODE_VERSION:-}" _CLI_CCX="${CCX_VERSION:-}" _CLI_COPILOT="${COPILOT_API_VERSION:-}" _CLI_PLAYWRIGHT="${PLAYWRIGHT_VERSION:-}" @@ -70,7 +71,7 @@ Options: --only LIST Upgrade only these tools (comma-separated); the rest stay pinned to versions.env. Tools: claude-code, cctrace, codex, gemini-cli, grok-cli, kimi-code, - ccx, copilot-api, playwright, cloakbrowser + opencode, ccx, copilot-api, playwright, cloakbrowser -h, --help Show this help Environment: @@ -87,6 +88,7 @@ Environment: GEMINI_CLI_VERSION Override gemini-cli version GROK_CLI_VERSION Override grok-cli version KIMI_CODE_VERSION Override kimi-code version + OPENCODE_VERSION Override opencode version CCX_VERSION Override ccx version COPILOT_API_VERSION Override copilot-api version PLAYWRIGHT_VERSION Override playwright version (rust image only) @@ -116,7 +118,7 @@ apply_only_filter() { [[ -n $ONLY ]] || return 0 local tool - local known="claude-code cctrace codex gemini-cli grok-cli kimi-code ccx copilot-api playwright cloakbrowser" + local known="claude-code cctrace codex gemini-cli grok-cli kimi-code opencode ccx copilot-api playwright cloakbrowser" for tool in ${ONLY//,/ }; do case " $known " in *" $tool "*) ;; @@ -132,6 +134,7 @@ apply_only_filter() { tool_selected gemini-cli || _CLI_GEMINI="${_CLI_GEMINI:-$GEMINI_CLI_VERSION}" tool_selected grok-cli || _CLI_GROK="${_CLI_GROK:-$GROK_CLI_VERSION}" tool_selected kimi-code || _CLI_KIMI="${_CLI_KIMI:-$KIMI_CODE_VERSION}" + tool_selected opencode || _CLI_OPENCODE="${_CLI_OPENCODE:-$OPENCODE_VERSION}" tool_selected ccx || _CLI_CCX="${_CLI_CCX:-$CCX_VERSION}" tool_selected copilot-api || _CLI_COPILOT="${_CLI_COPILOT:-$COPILOT_API_VERSION}" tool_selected playwright || _CLI_PLAYWRIGHT="${_CLI_PLAYWRIGHT:-$PLAYWRIGHT_VERSION}" @@ -204,13 +207,14 @@ main() { # Resolve build versions early so we can show the manifest before countdown. # CLI override wins; otherwise use whatever load_versions fetched. - local claude_ver cctrace_ver codex_ver gemini_ver grok_ver kimi_ver ccx_ver copilot_ver playwright_ver + local claude_ver cctrace_ver codex_ver gemini_ver grok_ver kimi_ver opencode_ver ccx_ver copilot_ver playwright_ver claude_ver="${_CLI_CLAUDE_CODE:-$(get_latest "claude-code")}" cctrace_ver="${_CLI_CCTRACE:-$(get_latest "cctrace")}" codex_ver="${_CLI_CODEX:-$(get_latest "codex")}" gemini_ver="${_CLI_GEMINI:-$(get_latest "gemini-cli")}" grok_ver="${_CLI_GROK:-$(get_latest "grok-cli")}" kimi_ver="${_CLI_KIMI:-$(get_latest "kimi-code")}" + opencode_ver="${_CLI_OPENCODE:-$(get_latest "opencode")}" ccx_ver="${_CLI_CCX:-$(get_latest "ccx")}" copilot_ver="${_CLI_COPILOT:-$(get_latest "copilot-api")}" playwright_ver="${_CLI_PLAYWRIGHT:-${PLAYWRIGHT_VERSION}}" @@ -221,6 +225,7 @@ main() { [[ -z $gemini_ver ]] && missing+=("GEMINI_CLI_VERSION") [[ -z $grok_ver ]] && missing+=("GROK_CLI_VERSION") [[ -z $kimi_ver ]] && missing+=("KIMI_CODE_VERSION") + [[ -z $opencode_ver ]] && missing+=("OPENCODE_VERSION") [[ -z $ccx_ver ]] && missing+=("CCX_VERSION") [[ -z $copilot_ver ]] && missing+=("COPILOT_API_VERSION") [[ -z $playwright_ver ]] && missing+=("PLAYWRIGHT_VERSION") @@ -239,6 +244,7 @@ main() { "Codex|codex_ver|_CLI_CODEX|codex" "Grok CLI|grok_ver|_CLI_GROK|grok-cli" "Kimi Code|kimi_ver|_CLI_KIMI|kimi-code" + "opencode|opencode_ver|_CLI_OPENCODE|opencode" "CCX|ccx_ver|_CLI_CCX|ccx" "Copilot API|copilot_ver|_CLI_COPILOT|copilot-api" "Playwright|playwright_ver|_CLI_PLAYWRIGHT|playwright" @@ -363,6 +369,7 @@ main() { --build-arg GEMINI_CLI_VERSION="$gemini_ver" \ --build-arg GROK_CLI_VERSION="$grok_ver" \ --build-arg KIMI_CODE_VERSION="$kimi_ver" \ + --build-arg OPENCODE_VERSION="$opencode_ver" \ --build-arg CCX_VERSION="$ccx_ver" \ --build-arg COPILOT_API_VERSION="$copilot_ver" \ -t "$BUILD_IMAGE" . @@ -378,6 +385,7 @@ main() { --build-arg GEMINI_CLI_VERSION="$gemini_ver" \ --build-arg GROK_CLI_VERSION="$grok_ver" \ --build-arg KIMI_CODE_VERSION="$kimi_ver" \ + --build-arg OPENCODE_VERSION="$opencode_ver" \ --build-arg CCX_VERSION="$ccx_ver" \ --build-arg PLAYWRIGHT_VERSION="$playwright_ver" \ --build-arg RUST_TOOLCHAINS="$RUST_TOOLCHAINS" \ @@ -407,6 +415,7 @@ main() { GEMINI_CLI_VERSION="$gemini_ver" GROK_CLI_VERSION="$grok_ver" KIMI_CODE_VERSION="$kimi_ver" + OPENCODE_VERSION="$opencode_ver" CCX_VERSION="$ccx_ver" COPILOT_API_VERSION="$copilot_ver" PLAYWRIGHT_VERSION="$playwright_ver" diff --git a/scripts/versions-pr.sh b/scripts/versions-pr.sh index 39038cd..2b4dd51 100755 --- a/scripts/versions-pr.sh +++ b/scripts/versions-pr.sh @@ -63,6 +63,7 @@ pin_label() { GEMINI_CLI_VERSION) echo "gemini-cli" ;; GROK_CLI_VERSION) echo "grok" ;; KIMI_CODE_VERSION) echo "kimi-code" ;; + OPENCODE_VERSION) echo "opencode" ;; CCX_VERSION) echo "ccx" ;; COPILOT_API_VERSION) echo "copilot-api" ;; PLAYWRIGHT_VERSION) echo "playwright" ;; diff --git a/tests/test_release_utils.sh b/tests/test_release_utils.sh index 678b7bc..08d8400 100644 --- a/tests/test_release_utils.sh +++ b/tests/test_release_utils.sh @@ -129,7 +129,7 @@ assert_eq "caller 'image' not clobbered" "caller-image" "${leak_after##*|}" # ───── get_tools_by_group ───── section "get_tools_by_group" agent_tools="$(get_tools_by_group agent | sort | tr '\n' ' ' | sed 's/ $//')" -expected_agent="cctrace ccx claude-code codex copilot-api gemini-cli grok-cli kimi-code" +expected_agent="cctrace ccx claude-code codex copilot-api gemini-cli grok-cli kimi-code opencode" assert_eq "group=agent" "$expected_agent" "$agent_tools" browser_tools="$(get_tools_by_group browser | sort | tr '\n' ' ' | sed 's/ $//')" @@ -144,7 +144,7 @@ assert_eq "group=does-not-exist" "" "$nonexistent_group" # ───── get_tools_by_image ───── section "get_tools_by_image" main_tools="$(get_tools_by_image main | sort | tr '\n' ' ' | sed 's/ $//')" -expected_main="cctrace ccx claude-code codex copilot-api gemini-cli grok-cli kimi-code" +expected_main="cctrace ccx claude-code codex copilot-api gemini-cli grok-cli kimi-code opencode" assert_eq "image=main" "$expected_main" "$main_tools" rust_tools="$(get_tools_by_image rust | sort | tr '\n' ' ' | sed 's/ $//')" @@ -156,8 +156,8 @@ assert_eq "image=base (empty until Step 3)" "" "$base_tools" # ───── filter_tools: scope resolution ───── section "filter_tools: default (no scope)" default_all="$(unset GROUP TOOL IMAGE; filter_tools | sort | tr '\n' ' ' | sed 's/ $//')" -expected_all="cctrace ccx claude-code codex copilot-api gemini-cli grok-cli kimi-code playwright" -assert_eq "default returns all 9" "$expected_all" "$default_all" +expected_all="cctrace ccx claude-code codex copilot-api gemini-cli grok-cli kimi-code opencode playwright" +assert_eq "default returns all 10" "$expected_all" "$default_all" section "filter_tools: TOOL= scope" single="$(TOOL=claude-code GROUP= IMAGE= filter_tools)" diff --git a/tests/version-upgrade.sh b/tests/version-upgrade.sh index 72146b5..3bd03c4 100644 --- a/tests/version-upgrade.sh +++ b/tests/version-upgrade.sh @@ -28,6 +28,7 @@ inspect) "org.opencontainers.image.gemini_cli_version":"0.35.0", "org.opencontainers.image.grok_cli_version":"0.2.90", "org.opencontainers.image.kimi_code_version":"0.28.0", + "org.opencontainers.image.opencode_version":"1.18.14", "org.opencontainers.image.ccx_version":"v0.7.0", "org.opencontainers.image.copilot_api_version":"0ea08febdd7e3e055b03dd298bf57e669500b5c1", "org.opencontainers.image.playwright_version":"1.59.0" @@ -105,6 +106,7 @@ case "$url" in */-/package/@google/gemini-cli/dist-tags) echo '{"latest":"0.35.3"}' ;; */-/package/@xai-official/grok/dist-tags) echo '{"latest":"0.2.93"}' ;; */-/package/@moonshot-ai/kimi-code/dist-tags) echo '{"latest":"0.28.0"}' ;; +*/-/package/opencode-ai/dist-tags) echo '{"latest":"1.18.14"}' ;; */-/package/playwright/dist-tags) echo '{"latest":"1.60.0"}' ;; */-/package/cloakbrowser/dist-tags) echo '{"latest":"0.6.0"}' ;; *registry.npmjs.org/@anthropic-ai%2fclaude-code) echo '{"time":{"2.1.87":"2026-03-29T01:40:00Z"}}' ;; @@ -113,6 +115,7 @@ case "$url" in *registry.npmjs.org/@google%2fgemini-cli) echo '{"time":{"0.35.3":"2026-03-28T03:17:00Z"}}' ;; *registry.npmjs.org/@xai-official%2fgrok) echo '{"time":{"0.2.93":"2026-07-01T00:00:00Z"}}' ;; *registry.npmjs.org/@moonshot-ai%2fkimi-code) echo '{"time":{"0.28.0":"2026-07-10T00:00:00Z"}}' ;; +*registry.npmjs.org/opencode-ai) echo '{"time":{"1.18.14":"2026-08-01T00:00:00Z"}}' ;; *registry.npmjs.org/playwright) echo '{"time":{"1.60.0":"2026-05-14T08:00:00Z"}}' ;; *) echo "unexpected curl url: $url" >&2 @@ -361,6 +364,7 @@ case "$url" in */-/package/@google/gemini-cli/dist-tags) echo '{"latest":"0.35.3"}' ;; */-/package/@xai-official/grok/dist-tags) echo '{"latest":"0.2.93"}' ;; */-/package/@moonshot-ai/kimi-code/dist-tags) echo '{"latest":"0.28.0"}' ;; +*/-/package/opencode-ai/dist-tags) echo '{"latest":"1.18.14"}' ;; */-/package/playwright/dist-tags) echo '{"latest":"1.60.0"}' ;; */-/package/cloakbrowser/dist-tags) echo '{"latest":"0.6.0"}' ;; *registry.npmjs.org/*) echo '{"time":{}}' ;; diff --git a/versions.env b/versions.env index a337101..46c70ad 100644 --- a/versions.env +++ b/versions.env @@ -14,6 +14,7 @@ CODEX_VERSION=0.146.0 GEMINI_CLI_VERSION=0.53.0 GROK_CLI_VERSION=0.2.114 KIMI_CODE_VERSION=0.30.0 +OPENCODE_VERSION=1.18.14 CCX_VERSION=v0.12.0 COPILOT_API_VERSION=0ea08febdd7e3e055b03dd298bf57e669500b5c1 PLAYWRIGHT_VERSION=1.62.0