From 2b831cd54415593b306e9f0fdc8ef0820a23d10e Mon Sep 17 00:00:00 2001 From: laurenceputra Date: Wed, 2 Sep 2026 09:23:01 +0000 Subject: [PATCH 1/4] fix: make self-updates complete and retry-safe --- .github/workflows/ci.yml | 4 + .opencode_web_yolo.manifest | 16 + .opencode_web_yolo.sh | 317 +++++- CHANGELOG.md | 4 + README.md | 8 + TECHNICAL.md | 8 +- VERSION | 2 +- install.sh | 273 +++++- skills/opencode-web-release/SKILL.md | 9 +- .../references/install-layout.md | 3 + .../references/update-reexec-sequence.md | 11 +- .../fixtures/old-0.1.10/.opencode_web_yolo.sh | 914 ++++++++++++++++++ tests/test_health.sh | 1 + tests/test_helpers.sh | 61 +- tests/test_install_bootstrap.sh | 86 +- tests/test_self_update.sh | 207 +++- tests/version_guard.sh | 1 + 17 files changed, 1804 insertions(+), 121 deletions(-) create mode 100644 .opencode_web_yolo.manifest create mode 100644 tests/fixtures/old-0.1.10/.opencode_web_yolo.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b71227..92990ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ jobs: bash -n .opencode_web_yolo_entrypoint.sh bash -n .opencode_web_yolo_runtime.sh bash -n install.sh + bash -n tests/fixtures/old-0.1.10/.opencode_web_yolo.sh bash -n tests/test_helpers.sh bash -n tests/test_dry_run.sh bash -n tests/test_build_expected_version.sh @@ -32,6 +33,7 @@ jobs: bash -n tests/test_playwright_dockerfile_contract.sh bash -n tests/test_version_command.sh bash -n tests/test_self_update.sh + bash -n tests/test_install_bootstrap.sh bash -n tests/test_auth_required.sh bash -n tests/test_sensitive_mounts.sh bash -n tests/test_spec_ignore.sh @@ -48,6 +50,7 @@ jobs: shellcheck .opencode_web_yolo_entrypoint.sh shellcheck .opencode_web_yolo_runtime.sh shellcheck install.sh + shellcheck tests/fixtures/old-0.1.10/.opencode_web_yolo.sh shellcheck -x tests/test_helpers.sh shellcheck -x tests/test_dry_run.sh shellcheck -x tests/test_build_expected_version.sh @@ -63,6 +66,7 @@ jobs: shellcheck -x tests/test_playwright_dockerfile_contract.sh shellcheck -x tests/test_version_command.sh shellcheck -x tests/test_self_update.sh + shellcheck -x tests/test_install_bootstrap.sh shellcheck -x tests/test_auth_required.sh shellcheck -x tests/test_sensitive_mounts.sh shellcheck -x tests/test_spec_ignore.sh diff --git a/.opencode_web_yolo.manifest b/.opencode_web_yolo.manifest new file mode 100644 index 0000000..5f49a65 --- /dev/null +++ b/.opencode_web_yolo.manifest @@ -0,0 +1,16 @@ +.opencode_web_yolo.manifest +.opencode_web_yolo.sh +.opencode_web_yolo_config.sh +.opencode_web_yolo.Dockerfile +.opencode_web_yolo_entrypoint.sh +.opencode_web_yolo_runtime.sh +.opencode_web_yolo_retention.js +.opencode_web_yolo_completion.bash +.opencode_web_yolo_completion.zsh +install.sh +VERSION +CHANGELOG.md +README.md +TECHNICAL.md +LICENSE +CODEOWNERS diff --git a/.opencode_web_yolo.sh b/.opencode_web_yolo.sh index b9cc96e..64e45d5 100755 --- a/.opencode_web_yolo.sh +++ b/.opencode_web_yolo.sh @@ -86,9 +86,40 @@ require_command() { } version_gt() { - local left="$1" - local right="$2" - [ "$left" != "$right" ] && [ "$(printf '%s\n%s\n' "$left" "$right" | sort -V | tail -n 1)" = "$left" ] + local left="$1" right="$2" + local left_major left_minor left_patch right_major right_minor right_patch + + [[ "$left" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 1 + left_major="${BASH_REMATCH[1]}" + left_minor="${BASH_REMATCH[2]}" + left_patch="${BASH_REMATCH[3]}" + [[ "$right" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 1 + right_major="${BASH_REMATCH[1]}" + right_minor="${BASH_REMATCH[2]}" + right_patch="${BASH_REMATCH[3]}" + + while [ "${left_major#0}" != "$left_major" ]; do left_major="${left_major#0}"; done + while [ "${left_minor#0}" != "$left_minor" ]; do left_minor="${left_minor#0}"; done + while [ "${left_patch#0}" != "$left_patch" ]; do left_patch="${left_patch#0}"; done + while [ "${right_major#0}" != "$right_major" ]; do right_major="${right_major#0}"; done + while [ "${right_minor#0}" != "$right_minor" ]; do right_minor="${right_minor#0}"; done + while [ "${right_patch#0}" != "$right_patch" ]; do right_patch="${right_patch#0}"; done + left_major="${left_major:-0}" + left_minor="${left_minor:-0}" + left_patch="${left_patch:-0}" + right_major="${right_major:-0}" + right_minor="${right_minor:-0}" + right_patch="${right_patch:-0}" + + if ((left_major != right_major)); then + ((left_major > right_major)) + elif ((left_minor != right_minor)); then + ((left_minor > right_minor)) + elif ((left_patch != right_patch)); then + ((left_patch > right_patch)) + else + return 1 + fi } expand_tilde() { @@ -132,8 +163,9 @@ resolve_repo_from_origin() { esac } -managed_files() { +fallback_managed_files() { cat <<'EOF' +.opencode_web_yolo.manifest .opencode_web_yolo.sh .opencode_web_yolo_config.sh .opencode_web_yolo.Dockerfile @@ -147,11 +179,228 @@ VERSION CHANGELOG.md README.md TECHNICAL.md +LICENSE +CODEOWNERS EOF } +manifest_has_canonical_files() { + local manifest_file="$1" canonical_file manifest_entry + local seen_manifest_file + + [ -f "$manifest_file" ] || return 1 + [ -s "$manifest_file" ] || return 1 + seen_manifest_file="$(mktemp "${TMPDIR:-/tmp}/opencode_web_yolo-manifest.XXXXXX")" || return 1 + + while IFS= read -r manifest_entry || [ -n "$manifest_entry" ]; do + [ -n "$manifest_entry" ] || continue + case "$manifest_entry" in + *[!A-Za-z0-9._-]*) rm -f "$seen_manifest_file"; return 1 ;; + esac + if grep -Fqx -- "$manifest_entry" "$seen_manifest_file"; then + rm -f "$seen_manifest_file" + return 1 + fi + if ! printf '%s\n' "$manifest_entry" >>"$seen_manifest_file"; then + rm -f "$seen_manifest_file" + return 1 + fi + done <"$manifest_file" + + while IFS= read -r canonical_file; do + if ! grep -Fqx -- "$canonical_file" "$manifest_file"; then + rm -f "$seen_manifest_file" + return 1 + fi + done < <(fallback_managed_files) + rm -f "$seen_manifest_file" +} + +managed_files_for_dir() { + local source_dir="$1" + local manifest_file="${source_dir}/.opencode_web_yolo.manifest" + + if manifest_has_canonical_files "$manifest_file"; then + cat "$manifest_file" + else + fallback_managed_files + fi +} + +managed_files() { + managed_files_for_dir "$SCRIPT_DIR" +} + +validate_managed_tree() { + local source_dir="$1" manifest_file required_file required_path version + + manifest_file="${source_dir}/.opencode_web_yolo.manifest" + if [ -e "$manifest_file" ] && ! manifest_has_canonical_files "$manifest_file"; then + return 1 + fi + + while IFS= read -r required_file || [ -n "$required_file" ]; do + [ -n "$required_file" ] || continue + required_path="${source_dir}/${required_file}" + [ -f "$required_path" ] || return 1 + [ ! -L "$required_path" ] || return 1 + [ -s "$required_path" ] || return 1 + case "$required_file" in + *.sh|*.bash) + bash -n "$required_path" >/dev/null 2>&1 || return 1 + ;; + esac + done < <(managed_files_for_dir "$source_dir") + + version="$(tr -d '[:space:]' <"${source_dir}/VERSION")" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 +} + +url_encode_branch() { + local branch="$1" encoded="" character byte index + + for ((index = 0; index < ${#branch}; index++)); do + character="${branch:index:1}" + case "$character" in + [A-Za-z0-9._~-]|/) encoded+="$character" ;; + *) + printf -v byte '%02X' "'${character}" + encoded+="%${byte}" + ;; + esac + done + printf '%s\n' "$encoded" +} + +validate_archive_path() { + local path="$1" component remainder + + case "$path" in + ""|/*|*//* ) return 1 ;; + esac + remainder="$path" + while :; do + if [[ "$remainder" == */* ]]; then + component="${remainder%%/*}" + remainder="${remainder#*/}" + else + component="$remainder" + remainder="" + fi + case "$component" in + ""|.|..) return 1 ;; + esac + [ -n "$remainder" ] || break + done +} + +validate_archive_contents() { + local archive_file="$1" archive_root="" archive_entry relative_entry listing type_char + local root_directory_seen=0 + + if ! tar -tzf "$archive_file" >/dev/null 2>&1 || ! tar -tvzf "$archive_file" >/dev/null 2>&1; then + return 1 + fi + while IFS= read -r listing; do + [ -n "$listing" ] || continue + type_char="${listing:0:1}" + case "$type_char" in + -|d) ;; + *) return 1 ;; + esac + done < <(tar -tvzf "$archive_file") + + while IFS= read -r archive_entry; do + [ -n "$archive_entry" ] || continue + case "$archive_entry" in + */*) + archive_root="${archive_entry%%/*}" + break + ;; + *) return 1 ;; + esac + done < <(tar -tzf "$archive_file") + validate_archive_path "$archive_root" || return 1 + while IFS= read -r archive_entry; do + [ -n "$archive_entry" ] || continue + case "$archive_entry" in + "${archive_root}/"*) + relative_entry="${archive_entry#"${archive_root}/"}" + validate_archive_path "$archive_entry" || return 1 + [ -n "$relative_entry" ] || root_directory_seen=1 + ;; + *) return 1 ;; + esac + done < <(tar -tzf "$archive_file") + [ "$root_directory_seen" -eq 1 ] || return 1 +} + +download_release_snapshot() { + local destination_dir="$1" repo="$2" branch="$3" + local archive_file extract_dir archive_url + + require_command tar + branch="$(url_encode_branch "$branch")" + archive_url="https://github.com/${repo}/archive/refs/heads/${branch}.tar.gz" + archive_file="${destination_dir}/release.tar.gz" + extract_dir="${destination_dir}/release" + mkdir -p "$extract_dir" + + if ! curl -fsSL "$archive_url" -o "$archive_file"; then + die "Failed downloading release archive from ${archive_url}." + fi + if ! validate_archive_contents "$archive_file"; then + die "Downloaded release archive from ${repo}@${branch} is malformed or truncated." + fi + + if ! tar -xzf "$archive_file" -C "$extract_dir" --strip-components=1; then + die "Failed extracting release archive from ${repo}@${branch}." + fi + printf '%s\n' "$extract_dir" +} + +promote_release() { + local source_dir="$1" install_home="$2" managed_file source_file destination_file + + mkdir -p "$install_home" + chmod +x "${source_dir}/.opencode_web_yolo.sh" "${source_dir}/.opencode_web_yolo_entrypoint.sh" "${source_dir}/install.sh" + while IFS= read -r managed_file || [ -n "$managed_file" ]; do + [ -n "$managed_file" ] || continue + case "$managed_file" in + .opencode_web_yolo.sh|VERSION) continue ;; + esac + source_file="${source_dir}/${managed_file}" + destination_file="${install_home}/${managed_file}" + mkdir -p "$(dirname "$destination_file")" + # Test-only interruption hook; normal installs never set this variable. + if [ "${OPENCODE_WEB_YOLO_TEST_FAIL_PROMOTION_ON:-}" = "$managed_file" ]; then + die "Test promotion interruption requested for '${managed_file}'." + fi + mv -f "$source_file" "$destination_file" + done < <(managed_files_for_dir "$source_dir") + + mv -f "${source_dir}/.opencode_web_yolo.sh" "${install_home}/.opencode_web_yolo.sh" + if [ "${OPENCODE_WEB_YOLO_TEST_FAIL_PROMOTION_ON:-}" = "after-wrapper" ]; then + die "Test promotion interruption requested after wrapper promotion." + fi + mv -f "${source_dir}/VERSION" "${install_home}/VERSION" +} + apply_self_update() { - local install_home repo branch local_version remote_version remote_base tmpdir managed_file src_file dst_file + local install_home repo branch branch_url local_version remote_version remote_base tmpdir staged_dir + local local_complete=1 staged_version + + if ! validate_managed_tree "$SCRIPT_DIR"; then + local_complete=0 + fi + + if is_true "${OPENCODE_WEB_UPDATE_REEXECED:-0}"; then + if [ "$local_complete" -ne 1 ]; then + die "Managed install is incomplete after self-update; refusing to build or re-exec." + fi + debug "Self-update re-exec already completed; skipping another update check." + return 0 + fi if is_true "${OPENCODE_WEB_SKIP_UPDATE_CHECK}"; then debug "Skipping update check because OPENCODE_WEB_SKIP_UPDATE_CHECK is enabled." @@ -181,44 +430,52 @@ apply_self_update() { fi local_version="$WRAPPER_VERSION" - remote_base="https://raw.githubusercontent.com/${repo}/${branch}" + branch_url="$(url_encode_branch "$branch")" + remote_base="https://raw.githubusercontent.com/${repo}/${branch_url}" if ! remote_version="$(curl -fsSL "${remote_base}/VERSION" | tr -d '[:space:]')"; then warn "Update check failed while reading remote VERSION from ${repo}@${branch}. Continuing with local files." return 0 fi + if [[ ! "$remote_version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + warn "Ignoring invalid remote VERSION '${remote_version}' from ${repo}@${branch}." + return 0 + fi - if ! version_gt "$remote_version" "$local_version"; then + if [ "$local_complete" -eq 1 ] && ! version_gt "$remote_version" "$local_version"; then debug "Local version (${local_version}) is up to date." return 0 fi - log "Updating wrapper from ${local_version} to ${remote_version}." - tmpdir="$(mktemp -d)" - trap 'rm -rf "$tmpdir"' EXIT - - while IFS= read -r managed_file; do - src_file="${remote_base}/${managed_file}" - dst_file="${tmpdir}/${managed_file}" - mkdir -p "$(dirname "$dst_file")" - if ! curl -fsSL "$src_file" -o "$dst_file"; then - die "Failed downloading '${managed_file}' during self-update." - fi - done < <(managed_files) - - while IFS= read -r managed_file; do - dst_file="${install_home}/${managed_file}" - mkdir -p "$(dirname "$dst_file")" - cp "${tmpdir}/${managed_file}" "$dst_file" - done < <(managed_files) + if version_gt "$remote_version" "$local_version"; then + log "Updating wrapper from ${local_version} to ${remote_version}." + else + log "Repairing incomplete managed install at version ${local_version}." + fi - chmod +x "${install_home}/.opencode_web_yolo.sh" - chmod +x "${install_home}/.opencode_web_yolo_entrypoint.sh" - chmod +x "${install_home}/install.sh" + mkdir -p "$(dirname "$install_home")" "$install_home" + tmpdir="$(mktemp -d "${install_home}/.opencode_web_yolo-update.XXXXXX")" + trap 'rm -rf "${tmpdir:-}"' EXIT + staged_dir="$(download_release_snapshot "$tmpdir" "$repo" "$branch")" + if ! validate_managed_tree "$staged_dir"; then + die "Downloaded release archive from ${repo}@${branch} is missing, empty, or contains invalid managed files." + fi + staged_version="$(tr -d '[:space:]' <"${staged_dir}/VERSION")" + if [ "$staged_version" != "$remote_version" ]; then + die "Remote VERSION changed during self-update (checked ${remote_version}, archive contains ${staged_version}); refusing promotion." + fi + if ! version_gt "$staged_version" "$local_version" && [ "$local_complete" -eq 1 ]; then + die "Release archive version ${staged_version} cannot update local version ${local_version}." + fi + if [ "$local_complete" -eq 0 ] && version_gt "$local_version" "$staged_version"; then + die "Cannot repair incomplete version ${local_version} from older release ${staged_version}." + fi + promote_release "$staged_dir" "$install_home" rm -rf "$tmpdir" trap - EXIT log "Update complete, re-executing wrapper." + export OPENCODE_WEB_UPDATE_REEXECED=1 exec "${install_home}/.opencode_web_yolo.sh" "${ORIGINAL_ARGS[@]}" } @@ -800,6 +1057,10 @@ main() { apply_self_update + if ! validate_managed_tree "$SCRIPT_DIR"; then + die "Managed install is incomplete or invalid; refusing to build or launch Docker. Re-run install.sh to repair it." + fi + if is_true "${OPENCODE_WEB_AUTO_PULL}"; then OPENCODE_WEB_BUILD_PULL=1 fi diff --git a/CHANGELOG.md b/CHANGELOG.md index aad78bd..f12f822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to this project are documented here. +## [0.2.2] - 2026-09-02 + +- Fixed self-update and streamed installation to use a validated GitHub branch archive snapshot, including repair of incomplete installs at an equal version. Promotion is retry-safe with `VERSION` last, and malformed or incomplete releases fail closed before Docker build. Historical 0.1.10 installs on stock macOS/BSD may need the latest `install.sh` because their old updater depends on GNU `sort -V`. + ## [0.2.1] - 2026-09-01 - Hardened retention against cross-directory active descendants, stale root refreshes, stale DELETE responses, unsupported OpenCode versions, stalled APIs, unsafe pagination boundaries, signal/process-tree issues, and scheduler misconfiguration. Deletion verification now requires direct 404/not-found responses, and documentation explicitly records the API's residual delete-if-idle race. diff --git a/README.md b/README.md index 082a29f..ba20444 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,14 @@ Use `OPENCODE_WEB_DRY_RUN=1` or `--dry-run` to preview the exact docker command `opencode_web_yolo` now defaults to background mode and pull-on-start. Use `--foreground --no-pull` for attached/no-pull runs. If a container with the configured name already exists, wrapper launch replaces it (stops if running, then removes, then starts fresh). +### Self-update and repair + +Managed installs check the configured GitHub branch on startup. An update downloads one branch archive snapshot, validates the complete release (including the runtime supervisor and retention worker), and only then promotes it before re-executing with the original arguments and environment. The tracked `.opencode_web_yolo.manifest` controls the release file set. Incomplete installs are repaired even when their local `VERSION` equals the remote version; malformed or incomplete archives are rejected before Docker build. Set `OPENCODE_WEB_SKIP_UPDATE_CHECK=1` to skip network checks, but an incomplete managed install still fails closed and must be repaired with `install.sh`. + +Bootstrap installation from `curl | bash` uses the same archive-and-validation flow. `curl` and `tar` are required for streamed/bootstrap installs and self-update repairs. Branch names containing `/` are supported; other URL-significant branch characters are encoded safely. + +Recovery note for historical `0.1.10` installs: that old updater relies on GNU `sort -V`, which stock macOS/BSD `sort` does not provide. If such an install cannot start its updater, rerun the latest `install.sh`; the current wrapper's portable comparator cannot repair an updater that fails before it can launch the current wrapper. + ## Configuration Run `opencode_web_yolo config` to generate a sample config file at `~/.opencode_web_yolo/config`. diff --git a/TECHNICAL.md b/TECHNICAL.md index 09eb590..bc0e669 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -20,13 +20,16 @@ - `install.sh` supports two valid install flows: - repo-local install (`./install.sh`) using sibling managed files from the checkout - - streamed/bootstrap install (for example `curl -fsSL .../install.sh | bash`) that fetches managed files before install + - streamed/bootstrap install (for example `curl -fsSL .../install.sh | bash`) that fetches one branch archive before install - Bootstrap fetch source defaults: - repo: `OPENCODE_WEB_YOLO_REPO` when set - repo fallback: current git `origin` in `${PWD}` when available - final repo fallback: `laurenceputra/opencode_web_yolo` - branch: `OPENCODE_WEB_YOLO_BRANCH` (default `main`) - Installer always installs managed runtime files into `${OPENCODE_WEB_INSTALL_HOME:-$HOME/.opencode_web_yolo}` and symlinks command to `${OPENCODE_WEB_BIN_DIR:-$HOME/.local/bin}/opencode_web_yolo`. +- `.opencode_web_yolo.manifest` is the tracked release asset manifest. Installer and self-update validate every listed file as a non-empty regular file; shell assets are syntax-checked before promotion. +- Bootstrap and self-update require `curl` and `tar` when an archive is needed. Archive contents are staged on the install filesystem, validated as one snapshot, and promoted with same-filesystem file renames. The wrapper is promoted near-last and `VERSION` last so an interrupted update can be retried without falsely advancing the installed release. +- Archive validation rejects absolute or multi-root paths, any `.`/`..` path component, duplicate manifest entries, and every non-regular/non-directory tar entry (including links, devices, and FIFOs) before extraction. ## Security Model @@ -138,7 +141,8 @@ On run, unless disabled: - wrapper checks remote `VERSION` from `${OPENCODE_WEB_YOLO_REPO}` and `${OPENCODE_WEB_YOLO_BRANCH}`. - default repo: `laurenceputra/opencode_web_yolo` - default branch: `main` -- if remote version is newer, managed files are downloaded, replaced, and wrapper re-execs with original args. +- if remote version is newer, the complete source is downloaded from `https://github.com/${repo}/archive/refs/heads/${branch}.tar.gz`, extracted to install-home staging, validated against `.opencode_web_yolo.manifest`, promoted with atomic individual renames, and the wrapper re-execs with original args/environment. +- if the local managed install is incomplete, the same archive repair runs even when local and remote `VERSION` values are equal. A re-exec marker prevents an update loop, and an incomplete or malformed archive fails closed before Docker build. Update can be disabled with: - `OPENCODE_WEB_SKIP_UPDATE_CHECK=1` diff --git a/VERSION b/VERSION index 0c62199..ee1372d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.1 +0.2.2 diff --git a/install.sh b/install.sh index 234cf23..07b7572 100755 --- a/install.sh +++ b/install.sh @@ -12,23 +12,74 @@ BIN_DIR="${OPENCODE_WEB_BIN_DIR:-${HOME}/.local/bin}" DEFAULT_REPO="laurenceputra/opencode_web_yolo" DEFAULT_BRANCH="${OPENCODE_WEB_YOLO_BRANCH:-main}" -REQUIRED_FILES=( - ".opencode_web_yolo.sh" - ".opencode_web_yolo_config.sh" - ".opencode_web_yolo.Dockerfile" - ".opencode_web_yolo_entrypoint.sh" - ".opencode_web_yolo_runtime.sh" - ".opencode_web_yolo_retention.js" - ".opencode_web_yolo_completion.bash" - ".opencode_web_yolo_completion.zsh" - "install.sh" - "VERSION" - "CHANGELOG.md" - "README.md" - "TECHNICAL.md" - "LICENSE" - "CODEOWNERS" -) +fallback_required_files() { + cat <<'EOF' +.opencode_web_yolo.manifest +.opencode_web_yolo.sh +.opencode_web_yolo_config.sh +.opencode_web_yolo.Dockerfile +.opencode_web_yolo_entrypoint.sh +.opencode_web_yolo_runtime.sh +.opencode_web_yolo_retention.js +.opencode_web_yolo_completion.bash +.opencode_web_yolo_completion.zsh +install.sh +VERSION +CHANGELOG.md +README.md +TECHNICAL.md +LICENSE +CODEOWNERS +EOF +} + +manifest_has_canonical_files() { + local manifest_file="$1" canonical_file manifest_entry + local seen_manifest_file + + [ -f "$manifest_file" ] || return 1 + [ -s "$manifest_file" ] || return 1 + seen_manifest_file="$(mktemp "${TMPDIR:-/tmp}/opencode_web_yolo-manifest.XXXXXX")" || return 1 + while IFS= read -r manifest_entry || [ -n "$manifest_entry" ]; do + [ -n "$manifest_entry" ] || continue + case "$manifest_entry" in + *[!A-Za-z0-9._-]*) rm -f "$seen_manifest_file"; return 1 ;; + esac + if grep -Fqx -- "$manifest_entry" "$seen_manifest_file"; then + rm -f "$seen_manifest_file" + return 1 + fi + if ! printf '%s\n' "$manifest_entry" >>"$seen_manifest_file"; then + rm -f "$seen_manifest_file" + return 1 + fi + done <"$manifest_file" + while IFS= read -r canonical_file; do + if ! grep -Fqx -- "$canonical_file" "$manifest_file"; then + rm -f "$seen_manifest_file" + return 1 + fi + done < <(fallback_required_files) + rm -f "$seen_manifest_file" +} + +load_required_files() { + local source_dir="$1" manifest_file + + manifest_file="${source_dir}/.opencode_web_yolo.manifest" + + REQUIRED_FILES=() + if manifest_has_canonical_files "$manifest_file"; then + while IFS= read -r required_file || [ -n "$required_file" ]; do + [ -n "$required_file" ] || continue + REQUIRED_FILES+=("$required_file") + done <"$manifest_file" + else + while IFS= read -r required_file; do + REQUIRED_FILES+=("$required_file") + done < <(fallback_required_files) + fi +} is_stream_input() { case "$SOURCE_PATH" in @@ -66,44 +117,152 @@ resolve_repo_from_origin() { } has_required_files() { - local source_dir="$1" required_file + local source_dir="$1" required_file required_path version + load_required_files "$source_dir" + if [ -e "${source_dir}/.opencode_web_yolo.manifest" ] && ! manifest_has_canonical_files "${source_dir}/.opencode_web_yolo.manifest"; then + return 1 + fi for required_file in "${REQUIRED_FILES[@]}"; do - if [ ! -f "${source_dir}/${required_file}" ]; then + required_path="${source_dir}/${required_file}" + if [ ! -f "$required_path" ] || [ -L "$required_path" ] || [ ! -s "$required_path" ]; then return 1 fi + case "$required_file" in + *.sh|*.bash) bash -n "$required_path" >/dev/null 2>&1 || return 1 ;; + esac done + version="$(tr -d '[:space:]' <"${source_dir}/VERSION")" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1 + return 0 } -download_required_files() { - local destination_dir="$1" repo="$2" branch="$3" remote_base required_file target_file +url_encode_branch() { + local branch="$1" encoded="" character byte index + + for ((index = 0; index < ${#branch}; index++)); do + character="${branch:index:1}" + case "$character" in + [A-Za-z0-9._~-]|/) encoded+="$character" ;; + *) + printf -v byte '%02X' "'${character}" + encoded+="%${byte}" + ;; + esac + done + printf '%s\n' "$encoded" +} + +validate_archive_path() { + local path="$1" component remainder + + case "$path" in + ""|/*|*//* ) return 1 ;; + esac + remainder="$path" + while :; do + if [[ "$remainder" == */* ]]; then + component="${remainder%%/*}" + remainder="${remainder#*/}" + else + component="$remainder" + remainder="" + fi + case "$component" in + ""|.|..) return 1 ;; + esac + [ -n "$remainder" ] || break + done +} + +validate_archive_contents() { + local archive_file="$1" archive_root="" archive_entry relative_entry listing type_char + local root_directory_seen=0 + + if ! tar -tzf "$archive_file" >/dev/null 2>&1 || ! tar -tvzf "$archive_file" >/dev/null 2>&1; then + return 1 + fi + while IFS= read -r listing; do + [ -n "$listing" ] || continue + type_char="${listing:0:1}" + case "$type_char" in + -|d) ;; + *) return 1 ;; + esac + done < <(tar -tvzf "$archive_file") + + while IFS= read -r archive_entry; do + [ -n "$archive_entry" ] || continue + case "$archive_entry" in + */*) + archive_root="${archive_entry%%/*}" + break + ;; + *) return 1 ;; + esac + done < <(tar -tzf "$archive_file") + validate_archive_path "$archive_root" || return 1 + while IFS= read -r archive_entry; do + [ -n "$archive_entry" ] || continue + case "$archive_entry" in + "${archive_root}/"*) + relative_entry="${archive_entry#"${archive_root}/"}" + validate_archive_path "$archive_entry" || return 1 + [ -n "$relative_entry" ] || root_directory_seen=1 + ;; + *) return 1 ;; + esac + done < <(tar -tzf "$archive_file") + [ "$root_directory_seen" -eq 1 ] || return 1 +} + +download_release_snapshot() { + local destination_dir="$1" repo="$2" branch="$3" + local archive_file extract_dir archive_url if ! command -v curl >/dev/null 2>&1; then printf '%s\n' "[install] ERROR: curl is required to bootstrap install assets." >&2 exit 1 fi + if ! command -v tar >/dev/null 2>&1; then + printf '%s\n' "[install] ERROR: tar is required to extract bootstrap release assets." >&2 + exit 1 + fi - remote_base="https://raw.githubusercontent.com/${repo}/${branch}" - printf '%s\n' "[install] Fetching install assets from ${repo}@${branch}" + printf '%s\n' "[install] Fetching install assets from ${repo}@${branch}" >&2 + branch="$(url_encode_branch "$branch")" + archive_url="https://github.com/${repo}/archive/refs/heads/${branch}.tar.gz" + archive_file="${destination_dir}/release.tar.gz" + extract_dir="${destination_dir}/release" + mkdir -p "$extract_dir" + if ! curl -fsSL "$archive_url" -o "$archive_file"; then + printf '%s\n' "[install] ERROR: failed downloading release archive from ${archive_url}" >&2 + exit 1 + fi + if ! validate_archive_contents "$archive_file"; then + printf '%s\n' "[install] ERROR: downloaded release archive is malformed or truncated" >&2 + exit 1 + fi - for required_file in "${REQUIRED_FILES[@]}"; do - target_file="${destination_dir}/${required_file}" - mkdir -p "$(dirname "$target_file")" - if ! curl -fsSL "${remote_base}/${required_file}" -o "$target_file"; then - printf '%s\n' "[install] ERROR: failed downloading '${required_file}' from ${remote_base}" >&2 - exit 1 - fi - done + if ! tar -xzf "$archive_file" -C "$extract_dir" --strip-components=1; then + printf '%s\n' "[install] ERROR: failed extracting release archive" >&2 + exit 1 + fi + printf '%s\n' "$extract_dir" } SOURCE_DIR="$SCRIPT_DIR" BOOTSTRAP_DIR="" +PROMOTION_DIR="" cleanup() { if [ -n "$BOOTSTRAP_DIR" ] && [ -d "$BOOTSTRAP_DIR" ]; then rm -rf "$BOOTSTRAP_DIR" fi + if [ -n "$PROMOTION_DIR" ] && [ -d "$PROMOTION_DIR" ]; then + rm -rf "$PROMOTION_DIR" + fi } trap cleanup EXIT @@ -116,29 +275,59 @@ if is_stream_input || ! has_required_files "$SOURCE_DIR"; then repo="$DEFAULT_REPO" fi - BOOTSTRAP_DIR="$(mktemp -d)" - download_required_files "$BOOTSTRAP_DIR" "$repo" "$DEFAULT_BRANCH" - SOURCE_DIR="$BOOTSTRAP_DIR" + mkdir -p "$(dirname "$INSTALL_HOME")" + BOOTSTRAP_DIR="$(mktemp -d "${INSTALL_HOME}.bootstrap.XXXXXX")" + SOURCE_DIR="$(download_release_snapshot "$BOOTSTRAP_DIR" "$repo" "$DEFAULT_BRANCH")" +fi + +load_required_files "$SOURCE_DIR" +if ! has_required_files "$SOURCE_DIR"; then + printf '%s\n' "[install] ERROR: release is incomplete, contains empty managed files, or has invalid shell syntax." >&2 + exit 1 fi mkdir -p "${INSTALL_HOME}" "${BIN_DIR}" mkdir -p "${HOME}/.local/share/bash-completion/completions" mkdir -p "${HOME}/.zsh/completions" +PROMOTION_DIR="$(mktemp -d "${INSTALL_HOME}/.opencode_web_yolo-install.XXXXXX")" for file in "${REQUIRED_FILES[@]}"; do - cp "${SOURCE_DIR}/${file}" "${INSTALL_HOME}/${file}" + mkdir -p "$(dirname "${PROMOTION_DIR}/${file}")" + cp -p "${SOURCE_DIR}/${file}" "${PROMOTION_DIR}/${file}" +done + +chmod +x "${PROMOTION_DIR}/.opencode_web_yolo.sh" +chmod +x "${PROMOTION_DIR}/.opencode_web_yolo_entrypoint.sh" +chmod +x "${PROMOTION_DIR}/install.sh" + +if ! has_required_files "$PROMOTION_DIR"; then + printf '%s\n' "[install] ERROR: staged release validation failed; existing install was not promoted." >&2 + exit 1 +fi + +for file in "${REQUIRED_FILES[@]}"; do + case "$file" in + .opencode_web_yolo.sh|VERSION) continue ;; + esac + mkdir -p "$(dirname "${INSTALL_HOME}/${file}")" + mv -f "${PROMOTION_DIR}/${file}" "${INSTALL_HOME}/${file}" done +mv -f "${PROMOTION_DIR}/.opencode_web_yolo.sh" "${INSTALL_HOME}/.opencode_web_yolo.sh" -chmod +x "${INSTALL_HOME}/.opencode_web_yolo.sh" -chmod +x "${INSTALL_HOME}/.opencode_web_yolo_entrypoint.sh" -chmod +x "${INSTALL_HOME}/install.sh" || true +completion_tmp="$(mktemp "${HOME}/.local/share/bash-completion/completions/.opencode_web_yolo.XXXXXX")" +cp -p "${INSTALL_HOME}/.opencode_web_yolo_completion.bash" "$completion_tmp" +mv -f "$completion_tmp" "${HOME}/.local/share/bash-completion/completions/opencode_web_yolo" +completion_tmp="$(mktemp "${HOME}/.zsh/completions/.opencode_web_yolo.XXXXXX")" +cp -p "${INSTALL_HOME}/.opencode_web_yolo_completion.zsh" "$completion_tmp" +mv -f "$completion_tmp" "${HOME}/.zsh/completions/_opencode_web_yolo" ln -sfn "${INSTALL_HOME}/.opencode_web_yolo.sh" "${BIN_DIR}/opencode_web_yolo" -cp "${INSTALL_HOME}/.opencode_web_yolo_completion.bash" \ - "${HOME}/.local/share/bash-completion/completions/opencode_web_yolo" -cp "${INSTALL_HOME}/.opencode_web_yolo_completion.zsh" \ - "${HOME}/.zsh/completions/_opencode_web_yolo" +# VERSION is deliberately the final managed-file promotion. A retry after an +# interrupted install therefore remains visibly on the previous release. +mv -f "${PROMOTION_DIR}/VERSION" "${INSTALL_HOME}/VERSION" +rm -rf "$PROMOTION_DIR" +PROMOTION_DIR="" printf '%s\n' "[install] Installed to ${INSTALL_HOME}" printf '%s\n' "[install] Command symlink: ${BIN_DIR}/opencode_web_yolo" diff --git a/skills/opencode-web-release/SKILL.md b/skills/opencode-web-release/SKILL.md index 29e4683..7dd0257 100644 --- a/skills/opencode-web-release/SKILL.md +++ b/skills/opencode-web-release/SKILL.md @@ -30,11 +30,11 @@ Load only the file that matches the current release task: # Update Workflow -1. Read local `VERSION`. +1. Read local `VERSION` and validate the complete managed install. 2. Skip remote checks only when explicit skip flags/env vars are set. 3. Fetch remote `VERSION` from configured repo/branch. -4. When remote is newer, update distributed files atomically. -5. Re-exec wrapper after successful update. +4. When remote is newer, or when required files are missing at an equal version, stage and validate one branch archive before atomically promoting distributed files. +5. Re-exec wrapper after successful update, with a guard against update loops. # Image Rebuild Policy @@ -50,7 +50,8 @@ Record version metadata in the image so checks are deterministic. - Install to `~/.opencode_web_yolo` with predictable paths. - Ensure installed command points to the managed wrapper copy. -- Ship every runtime helper used by the Dockerfile (including the retention supervisor and worker) through both bootstrap installation and self-update managed-file lists. +- Ship every runtime helper used by the Dockerfile (including the retention supervisor and worker) through both bootstrap installation and self-update managed-file manifests/lists. +- Use the tracked `.opencode_web_yolo.manifest` for the complete release asset set. Bootstrap and self-update fetch one GitHub branch archive snapshot and validate every listed non-empty file before promotion, while retaining a compatibility fallback for historical wrappers that predate newly added assets. - Install/refresh bash and zsh completion scripts idempotently. - Fail with clear messages on partial installs. diff --git a/skills/opencode-web-release/references/install-layout.md b/skills/opencode-web-release/references/install-layout.md index 987b7f9..bf237f1 100644 --- a/skills/opencode-web-release/references/install-layout.md +++ b/skills/opencode-web-release/references/install-layout.md @@ -10,6 +10,7 @@ Use this file when editing `install.sh` or completion installation behavior. ## Required Installed Artifacts +- Tracked `.opencode_web_yolo.manifest` release-file manifest. - Wrapper script entrypoint. - Dockerfile and entrypoint assets required by runtime build. - Runtime supervisor and retention worker assets required by the enabled scheduler. @@ -21,6 +22,8 @@ Use this file when editing `install.sh` or completion installation behavior. ## Installer Behavior - Validate prerequisites before partial file writes where possible. +- Streamed/bootstrap installation downloads and validates one GitHub branch archive rather than per-file raw URLs. +- Promote staged files with same-filesystem renames and place `VERSION` last so an interrupted install can be retried. - Overwrite managed files intentionally on update. - Keep user-specific configs separate from managed runtime files. - Emit clear post-install usage and completion activation instructions. diff --git a/skills/opencode-web-release/references/update-reexec-sequence.md b/skills/opencode-web-release/references/update-reexec-sequence.md index 11d62e1..44e23e7 100644 --- a/skills/opencode-web-release/references/update-reexec-sequence.md +++ b/skills/opencode-web-release/references/update-reexec-sequence.md @@ -9,10 +9,12 @@ Use this sequence when implementing self-update behavior. 3. Resolve remote repo/branch and fetch remote `VERSION`. 4. Compare versions semantically. 5. If remote is newer: - - download/update managed runtime files - - verify required files are present after update + - download one `https://github.com/${repo}/archive/refs/heads/${branch}.tar.gz` snapshot + - extract it on the install filesystem and validate `VERSION`, the tracked managed-file manifest, every non-empty managed file, and shell syntax where applicable + - promote individual files atomically, with the wrapper near-last and `VERSION` last - re-exec wrapper with original arguments -6. If remote is not newer, continue normal execution. +6. If local managed files are incomplete, run the same archive repair even when versions are equal. A re-exec marker permits validation without looping. +7. If remote is not newer and the local install is complete, continue normal execution. ## Reliability Rules @@ -20,3 +22,6 @@ Use this sequence when implementing self-update behavior. - Preserve execute bits for scripts. - Preserve user args and environment during re-exec. - Fail closed with clear error on partial update. +- Require `tar` before archive extraction and reject malformed, unsafe, empty, or incomplete archives before promotion. +- Reject absolute, multi-root, dot/dotdot, symlink, hardlink, device, FIFO, and other non-regular archive entries before extraction; reject duplicate manifest entries. +- Keep `VERSION` unchanged when download, extraction, or validation fails so a retry remains possible. diff --git a/tests/fixtures/old-0.1.10/.opencode_web_yolo.sh b/tests/fixtures/old-0.1.10/.opencode_web_yolo.sh new file mode 100644 index 0000000..3340ea7 --- /dev/null +++ b/tests/fixtures/old-0.1.10/.opencode_web_yolo.sh @@ -0,0 +1,914 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOURCE_PATH="${BASH_SOURCE[0]}" +while [ -h "$SOURCE_PATH" ]; do + SOURCE_DIR="$(cd -P "$(dirname "$SOURCE_PATH")" && pwd)" + SOURCE_PATH="$(readlink "$SOURCE_PATH")" + case "$SOURCE_PATH" in + /*) ;; + *) SOURCE_PATH="${SOURCE_DIR}/${SOURCE_PATH}" ;; + esac +done +SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE_PATH")" && pwd)" +# shellcheck source=.opencode_web_yolo_config.sh +. "${SCRIPT_DIR}/.opencode_web_yolo_config.sh" + +ORIGINAL_ARGS=("$@") +WRAPPER_VERSION_FILE="${SCRIPT_DIR}/VERSION" +WRAPPER_VERSION="0.0.0" +if [ -f "$WRAPPER_VERSION_FILE" ]; then + WRAPPER_VERSION="$(tr -d '[:space:]' <"$WRAPPER_VERSION_FILE")" +fi + +VERBOSE="${OPENCODE_WEB_VERBOSE}" +PLAYWRIGHT_DEFAULT_VERSION="1.62.1" + +is_true() { + case "${1:-0}" in + 1|true|TRUE|yes|YES|on|ON) return 0 ;; + *) return 1 ;; + esac +} + +normalize_bool() { + if is_true "${1:-0}"; then + printf '%s\n' 1 + else + printf '%s\n' 0 + fi +} + +log() { + printf '%s\n' "[opencode_web_yolo] $*" +} + +warn() { + printf '%s\n' "[opencode_web_yolo] WARNING: $*" >&2 +} + +die() { + printf '%s\n' "[opencode_web_yolo] ERROR: $*" >&2 + exit 1 +} + +debug() { + if is_true "$VERBOSE"; then + printf '%s\n' "[opencode_web_yolo] DEBUG: $*" >&2 + fi +} + +require_command() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 || die "Required command '$cmd' was not found in PATH." +} + +version_gt() { + local left="$1" + local right="$2" + [ "$left" != "$right" ] && [ "$(printf '%s\n%s\n' "$left" "$right" | sort -V | tail -n 1)" = "$left" ] +} + +expand_tilde() { + local path="$1" + if [ "$path" = "~" ]; then + printf '%s\n' "$HOME" + return 0 + fi + + if [ "${path#\~/}" != "$path" ]; then + printf '%s\n' "${HOME}/${path#\~/}" + return 0 + fi + + printf '%s\n' "$path" +} + +resolve_repo_from_origin() { + local origin url + if ! command -v git >/dev/null 2>&1; then + return 0 + fi + + if ! origin="$(git -C "$SCRIPT_DIR" remote get-url origin 2>/dev/null)"; then + return 0 + fi + + url="${origin%.git}" + case "$url" in + git@github.com:*) + printf '%s\n' "${url#git@github.com:}" + ;; + https://github.com/*) + printf '%s\n' "${url#https://github.com/}" + ;; + http://github.com/*) + printf '%s\n' "${url#http://github.com/}" + ;; + *) + ;; + esac +} + +managed_files() { + cat <<'EOF' +.opencode_web_yolo.sh +.opencode_web_yolo_config.sh +.opencode_web_yolo.Dockerfile +.opencode_web_yolo_entrypoint.sh +.opencode_web_yolo_completion.bash +.opencode_web_yolo_completion.zsh +install.sh +VERSION +CHANGELOG.md +README.md +TECHNICAL.md +EOF +} + +apply_self_update() { + local install_home repo branch local_version remote_version remote_base tmpdir managed_file src_file dst_file + + if is_true "${OPENCODE_WEB_SKIP_UPDATE_CHECK}"; then + debug "Skipping update check because OPENCODE_WEB_SKIP_UPDATE_CHECK is enabled." + return 0 + fi + + install_home="${OPENCODE_WEB_INSTALL_HOME:-${HOME}/.opencode_web_yolo}" + if [ "$SCRIPT_DIR" != "$install_home" ]; then + debug "Skipping update check because wrapper is not running from managed install home (${install_home})." + return 0 + fi + + repo="${OPENCODE_WEB_YOLO_REPO:-}" + if [ -z "$repo" ]; then + repo="$(resolve_repo_from_origin || true)" + fi + branch="${OPENCODE_WEB_YOLO_BRANCH}" + + if [ -z "$repo" ]; then + debug "Skipping update check because OPENCODE_WEB_YOLO_REPO is not set and origin could not be resolved." + return 0 + fi + + if ! command -v curl >/dev/null 2>&1; then + warn "Skipping update check because curl is not available." + return 0 + fi + + local_version="$WRAPPER_VERSION" + remote_base="https://raw.githubusercontent.com/${repo}/${branch}" + if ! remote_version="$(curl -fsSL "${remote_base}/VERSION" | tr -d '[:space:]')"; then + warn "Update check failed while reading remote VERSION from ${repo}@${branch}. Continuing with local files." + return 0 + fi + + if ! version_gt "$remote_version" "$local_version"; then + debug "Local version (${local_version}) is up to date." + return 0 + fi + + log "Updating wrapper from ${local_version} to ${remote_version}." + tmpdir="$(mktemp -d)" + trap 'rm -rf "$tmpdir"' EXIT + + while IFS= read -r managed_file; do + src_file="${remote_base}/${managed_file}" + dst_file="${tmpdir}/${managed_file}" + mkdir -p "$(dirname "$dst_file")" + if ! curl -fsSL "$src_file" -o "$dst_file"; then + die "Failed downloading '${managed_file}' during self-update." + fi + done < <(managed_files) + + while IFS= read -r managed_file; do + dst_file="${install_home}/${managed_file}" + mkdir -p "$(dirname "$dst_file")" + cp "${tmpdir}/${managed_file}" "$dst_file" + done < <(managed_files) + + chmod +x "${install_home}/.opencode_web_yolo.sh" + chmod +x "${install_home}/.opencode_web_yolo_entrypoint.sh" + chmod +x "${install_home}/install.sh" + + rm -rf "$tmpdir" + trap - EXIT + + log "Update complete, re-executing wrapper." + exec "${install_home}/.opencode_web_yolo.sh" "${ORIGINAL_ARGS[@]}" +} + +print_version() { + printf '%s\n' "opencode_web_yolo ${WRAPPER_VERSION}" +} + +print_help() { + cat <"$config_file" <<'EOF' +# opencode_web_yolo user config +export OPENCODE_WEB_PORT=4096 +export OPENCODE_WEB_HOSTNAME=0.0.0.0 +export OPENCODE_WEB_YOLO_IMAGE=opencode_web_yolo:latest +export OPENCODE_WEB_BASE_IMAGE=node:22-slim +export OPENCODE_WEB_NPM_PACKAGE=opencode-ai +export OPENCODE_WEB_CONTAINER_NAME=opencode_web_yolo +export OPENCODE_WEB_RESTART_POLICY=unless-stopped +export OPENCODE_WEB_RUN_DETACHED=1 +export OPENCODE_WEB_AUTO_PULL=1 +export OPENCODE_WEB_BUILD_PLAYWRIGHT=0 +# Set OPENCODE_WEB_BUILD_PLAYWRIGHT=1 here to persist the Playwright build. +# This explicit pin remains effective even when version checks are skipped. +# export OPENCODE_WEB_EXPECTED_PLAYWRIGHT_VERSION=1.62.1 +export OPENCODE_WEB_BUILD_WRANGLER=0 +export OPENCODE_WEB_SKIP_UPDATE_CHECK=0 +export OPENCODE_WEB_SKIP_VERSION_CHECK=0 +# Required: set a non-empty password before running the server. +# export OPENCODE_SERVER_PASSWORD=change-me-now +# Optional: +# export OPENCODE_SERVER_USERNAME=opencode +# export OPENCODE_WEB_CONFIG_DIR=${XDG_CONFIG_HOME:-$HOME/.config}/opencode +# export OPENCODE_WEB_DATA_DIR=${XDG_DATA_HOME:-$HOME/.local/share}/opencode +# export OPENCODE_WEB_YOLO_REPO=laurenceputra/opencode_web_yolo +# export OPENCODE_WEB_YOLO_BRANCH=main +EOF + log "Wrote ${config_file}." +} + +show_health() { + local status=0 + local image_wrapper_version image_opencode_version image_playwright image_playwright_version image_playwright_expected_version image_wrangler + local runtime_home runtime_xdg_config runtime_xdg_data runtime_xdg_state + local container_home_env container_xdg_config_env container_xdg_data_env container_xdg_state_env + + runtime_home="${OPENCODE_WEB_YOLO_HOME}" + runtime_xdg_config="${OPENCODE_WEB_YOLO_HOME}/.config" + runtime_xdg_data="${OPENCODE_WEB_YOLO_HOME}/.local/share" + runtime_xdg_state="${OPENCODE_WEB_YOLO_HOME}/.local/share/opencode/state" + + printf '%s\n' "opencode_web_yolo health report" + printf '%s\n' " wrapper_version=${WRAPPER_VERSION}" + printf '%s\n' " image=${OPENCODE_WEB_YOLO_IMAGE}" + printf '%s\n' " port_binding=127.0.0.1:${OPENCODE_WEB_PORT}:${OPENCODE_WEB_PORT}" + printf '%s\n' " hostname=${OPENCODE_WEB_HOSTNAME}" + printf '%s\n' " config_file=${OPENCODE_WEB_CONFIG_FILE}" + printf '%s\n' " opencode_config_dir=${OPENCODE_WEB_CONFIG_DIR}" + printf '%s\n' " opencode_data_dir=${OPENCODE_WEB_DATA_DIR}" + printf '%s\n' " container_name=${OPENCODE_WEB_CONTAINER_NAME}" + printf '%s\n' " restart_policy=${OPENCODE_WEB_RESTART_POLICY}" + printf '%s\n' " run_detached=${OPENCODE_WEB_RUN_DETACHED}" + printf '%s\n' " auto_pull=${OPENCODE_WEB_AUTO_PULL}" + printf '%s\n' " build_pull=${OPENCODE_WEB_BUILD_PULL}" + printf '%s\n' " build_playwright=${OPENCODE_WEB_BUILD_PLAYWRIGHT}" + printf '%s\n' " build_wrangler=${OPENCODE_WEB_BUILD_WRANGLER}" + printf '%s\n' " runtime_env_home=${runtime_home}" + printf '%s\n' " runtime_env_xdg_config_home=${runtime_xdg_config}" + printf '%s\n' " runtime_env_xdg_data_home=${runtime_xdg_data}" + printf '%s\n' " runtime_env_xdg_state_home=${runtime_xdg_state}" + printf '%s\n' " workspace_ui_state_scope=browser-local-storage" + + if command -v docker >/dev/null 2>&1; then + printf '%s\n' " docker_cli=ok" + if docker info >/dev/null 2>&1; then + printf '%s\n' " docker_daemon=ok" + else + printf '%s\n' " docker_daemon=unavailable" + status=1 + fi + else + printf '%s\n' " docker_cli=missing" + status=1 + fi + + if docker image inspect "${OPENCODE_WEB_YOLO_IMAGE}" >/dev/null 2>&1; then + printf '%s\n' " image_present=yes" + image_wrapper_version="$(docker run --rm --entrypoint cat "${OPENCODE_WEB_YOLO_IMAGE}" /opt/opencode-web-yolo-version 2>/dev/null || true)" + image_opencode_version="$(docker run --rm --entrypoint cat "${OPENCODE_WEB_YOLO_IMAGE}" /opt/opencode-version 2>/dev/null || true)" + image_playwright="$(docker run --rm --entrypoint cat "${OPENCODE_WEB_YOLO_IMAGE}" /opt/opencode-web-yolo-playwright 2>/dev/null || true)" + image_playwright_version="$(docker run --rm --entrypoint cat "${OPENCODE_WEB_YOLO_IMAGE}" /opt/opencode-web-yolo-playwright-version 2>/dev/null || true)" + image_playwright_expected_version="$(docker run --rm --entrypoint cat "${OPENCODE_WEB_YOLO_IMAGE}" /opt/opencode-web-yolo-playwright-expected-version 2>/dev/null || true)" + image_wrangler="$(docker run --rm --entrypoint cat "${OPENCODE_WEB_YOLO_IMAGE}" /opt/opencode-web-yolo-wrangler 2>/dev/null || true)" + printf '%s\n' " image_wrapper_version=${image_wrapper_version:-unknown}" + printf '%s\n' " image_opencode_version=${image_opencode_version:-unknown}" + printf '%s\n' " image_build_playwright=${image_playwright:-unknown}" + printf '%s\n' " image_playwright_version=${image_playwright_version:-unknown}" + printf '%s\n' " image_playwright_expected_version=${image_playwright_expected_version:-unknown}" + printf '%s\n' " image_build_wrangler=${image_wrangler:-unknown}" + else + printf '%s\n' " image_present=no" + fi + + if command -v docker >/dev/null 2>&1; then + if [ -n "$(docker ps -a --filter "name=^/${OPENCODE_WEB_CONTAINER_NAME}$" --format '{{.Names}}' 2>/dev/null || true)" ]; then + printf '%s\n' " container_present=yes" + if [ -n "$(docker ps --filter "name=^/${OPENCODE_WEB_CONTAINER_NAME}$" --filter "status=running" --format '{{.Names}}' 2>/dev/null || true)" ]; then + printf '%s\n' " container_running=yes" + else + printf '%s\n' " container_running=no" + fi + + container_home_env="$(docker inspect "${OPENCODE_WEB_CONTAINER_NAME}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep -E '^HOME=' | tail -n 1 || true)" + container_xdg_config_env="$(docker inspect "${OPENCODE_WEB_CONTAINER_NAME}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep -E '^XDG_CONFIG_HOME=' | tail -n 1 || true)" + container_xdg_data_env="$(docker inspect "${OPENCODE_WEB_CONTAINER_NAME}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep -E '^XDG_DATA_HOME=' | tail -n 1 || true)" + container_xdg_state_env="$(docker inspect "${OPENCODE_WEB_CONTAINER_NAME}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep -E '^XDG_STATE_HOME=' | tail -n 1 || true)" + printf '%s\n' " container_env_home=${container_home_env:-missing}" + printf '%s\n' " container_env_xdg_config_home=${container_xdg_config_env:-missing}" + printf '%s\n' " container_env_xdg_data_home=${container_xdg_data_env:-missing}" + printf '%s\n' " container_env_xdg_state_home=${container_xdg_state_env:-missing}" + else + printf '%s\n' " container_present=no" + printf '%s\n' " container_env_home=missing" + printf '%s\n' " container_env_xdg_config_home=missing" + printf '%s\n' " container_env_xdg_data_home=missing" + printf '%s\n' " container_env_xdg_state_home=missing" + fi + fi + + if command -v gh >/dev/null 2>&1; then + printf '%s\n' " gh_cli=ok" + if gh auth status >/dev/null 2>&1; then + printf '%s\n' " gh_auth=ok" + else + printf '%s\n' " gh_auth=not-authenticated" + fi + else + printf '%s\n' " gh_cli=missing" + fi + + return "$status" +} + +resolve_expected_opencode_version() { + if is_true "${OPENCODE_WEB_SKIP_VERSION_CHECK}"; then + debug "Skipping OpenCode npm version check." + return 0 + fi + + if [ -n "${OPENCODE_WEB_EXPECTED_OPENCODE_VERSION:-}" ]; then + printf '%s\n' "${OPENCODE_WEB_EXPECTED_OPENCODE_VERSION}" + return 0 + fi + + if ! command -v npm >/dev/null 2>&1; then + warn "npm is not available; cannot evaluate OpenCode version drift." + return 0 + fi + + npm view "${OPENCODE_WEB_NPM_PACKAGE}" version --json 2>/dev/null | tr -d '"' | tr -d '[:space:]' +} + +resolve_expected_playwright_version() { + local resolved_version + + if ! is_true "${OPENCODE_WEB_BUILD_PLAYWRIGHT}"; then + return 0 + fi + + if [ -n "${OPENCODE_WEB_EXPECTED_PLAYWRIGHT_VERSION:-}" ]; then + printf '%s\n' "${OPENCODE_WEB_EXPECTED_PLAYWRIGHT_VERSION}" + return 0 + fi + + if is_true "${OPENCODE_WEB_SKIP_VERSION_CHECK}"; then + debug "Skipping Playwright npm version check." + return 0 + fi + + if command -v npm >/dev/null 2>&1; then + if resolved_version="$(npm view @playwright/test version --json 2>/dev/null | tr -d '"' | tr -d '[:space:]')" && [ -n "$resolved_version" ]; then + printf '%s\n' "$resolved_version" + return 0 + fi + warn "npm could not resolve the latest @playwright/test version; using pinned fallback ${PLAYWRIGHT_DEFAULT_VERSION}." + else + warn "npm is not available; using pinned fallback ${PLAYWRIGHT_DEFAULT_VERSION} for Playwright version checks." + fi + + printf '%s\n' "${PLAYWRIGHT_DEFAULT_VERSION}" +} + +build_image() { + local requested_opencode_version requested_playwright_version build_opencode_version build_playwright_version + local -a build_cmd + + requested_opencode_version="${1:-}" + build_opencode_version="latest" + if [ -n "$requested_opencode_version" ]; then + build_opencode_version="$requested_opencode_version" + elif [ -n "${OPENCODE_WEB_EXPECTED_OPENCODE_VERSION:-}" ]; then + build_opencode_version="${OPENCODE_WEB_EXPECTED_OPENCODE_VERSION}" + fi + + requested_playwright_version="${2:-}" + build_playwright_version="${requested_playwright_version:-${OPENCODE_WEB_EXPECTED_PLAYWRIGHT_VERSION:-${PLAYWRIGHT_DEFAULT_VERSION}}}" + + build_cmd=(docker build -f "${SCRIPT_DIR}/.opencode_web_yolo.Dockerfile") + if is_true "${OPENCODE_WEB_BUILD_PULL}"; then + build_cmd+=(--pull) + fi + if is_true "${OPENCODE_WEB_BUILD_NO_CACHE}"; then + build_cmd+=(--no-cache) + fi + + build_cmd+=( + --build-arg "BASE_IMAGE=${OPENCODE_WEB_BASE_IMAGE}" + --build-arg "WRAPPER_VERSION=${WRAPPER_VERSION}" + --build-arg "OPENCODE_NPM_PACKAGE=${OPENCODE_WEB_NPM_PACKAGE}" + --build-arg "OPENCODE_VERSION=${build_opencode_version}" + --build-arg "OPENCODE_WEB_BUILD_PLAYWRIGHT=${OPENCODE_WEB_BUILD_PLAYWRIGHT}" + --build-arg "PLAYWRIGHT_VERSION=${build_playwright_version}" + --build-arg "OPENCODE_WEB_BUILD_WRANGLER=${OPENCODE_WEB_BUILD_WRANGLER}" + -t "${OPENCODE_WEB_YOLO_IMAGE}" + "${SCRIPT_DIR}" + ) + + log "Building runtime image ${OPENCODE_WEB_YOLO_IMAGE} (opencode=${build_opencode_version}, playwright=${build_playwright_version})." + "${build_cmd[@]}" +} + +ensure_image() { + local expected_opencode_version expected_playwright_version image_wrapper_version image_opencode_version image_playwright image_playwright_version image_wrangler + local -a reasons + + reasons=() + expected_opencode_version="$(resolve_expected_opencode_version || true)" + expected_playwright_version="$(resolve_expected_playwright_version || true)" + + if ! docker image inspect "${OPENCODE_WEB_YOLO_IMAGE}" >/dev/null 2>&1; then + reasons+=("image '${OPENCODE_WEB_YOLO_IMAGE}' is missing") + fi + + if is_true "${OPENCODE_WEB_BUILD_PULL}"; then + reasons+=("pull rebuild requested") + fi + + if is_true "${OPENCODE_WEB_BUILD_NO_CACHE}"; then + reasons+=("no-cache rebuild requested") + fi + + if docker image inspect "${OPENCODE_WEB_YOLO_IMAGE}" >/dev/null 2>&1; then + image_wrapper_version="$(docker run --rm --entrypoint cat "${OPENCODE_WEB_YOLO_IMAGE}" /opt/opencode-web-yolo-version 2>/dev/null || true)" + if [ -z "$image_wrapper_version" ] || [ "$image_wrapper_version" != "$WRAPPER_VERSION" ]; then + reasons+=("wrapper version metadata mismatch (image='${image_wrapper_version:-missing}', local='${WRAPPER_VERSION}')") + fi + + image_opencode_version="$(docker run --rm --entrypoint cat "${OPENCODE_WEB_YOLO_IMAGE}" /opt/opencode-version 2>/dev/null || true)" + if [ -n "$expected_opencode_version" ] && [ "$image_opencode_version" != "$expected_opencode_version" ]; then + reasons+=("OpenCode version mismatch (image='${image_opencode_version:-missing}', expected='${expected_opencode_version}')") + fi + + image_playwright="$(docker run --rm --entrypoint cat "${OPENCODE_WEB_YOLO_IMAGE}" /opt/opencode-web-yolo-playwright 2>/dev/null || true)" + if [ "$image_playwright" != "${OPENCODE_WEB_BUILD_PLAYWRIGHT}" ]; then + reasons+=("Playwright build mismatch (image='${image_playwright:-missing}', expected='${OPENCODE_WEB_BUILD_PLAYWRIGHT}')") + fi + + if ! is_true "${OPENCODE_WEB_SKIP_VERSION_CHECK}" && is_true "${OPENCODE_WEB_BUILD_PLAYWRIGHT}" && [ -n "$expected_playwright_version" ]; then + image_playwright_version="$(docker run --rm --entrypoint cat "${OPENCODE_WEB_YOLO_IMAGE}" /opt/opencode-web-yolo-playwright-version 2>/dev/null || true)" + if [ "$image_playwright_version" != "$expected_playwright_version" ]; then + reasons+=("Playwright version mismatch (image='${image_playwright_version:-missing}', expected='${expected_playwright_version}')") + fi + fi + + image_wrangler="$(docker run --rm --entrypoint cat "${OPENCODE_WEB_YOLO_IMAGE}" /opt/opencode-web-yolo-wrangler 2>/dev/null || true)" + if [ "$image_wrangler" != "${OPENCODE_WEB_BUILD_WRANGLER}" ]; then + reasons+=("Wrangler build mismatch (image='${image_wrangler:-missing}', expected='${OPENCODE_WEB_BUILD_WRANGLER}')") + fi + fi + + if [ "${#reasons[@]}" -eq 0 ]; then + debug "Image checks passed; reusing ${OPENCODE_WEB_YOLO_IMAGE}." + return 0 + fi + + log "Rebuild required:" + for reason in "${reasons[@]}"; do + log " - ${reason}" + done + build_image "$expected_opencode_version" "$expected_playwright_version" +} + +require_password() { + local config_file_exists + if [ -z "${OPENCODE_SERVER_PASSWORD:-}" ]; then + config_file_exists="no" + if [ -f "${OPENCODE_WEB_CONFIG_FILE}" ]; then + config_file_exists="yes" + fi + + cat >&2 </dev/null || true)" + if [ -z "$existing_name" ]; then + return 0 + fi + + running_name="$(docker ps --filter "name=^/${OPENCODE_WEB_CONTAINER_NAME}$" --filter "status=running" --format '{{.Names}}' 2>/dev/null || true)" + if is_true "${OPENCODE_WEB_DRY_RUN}"; then + if [ -n "$running_name" ]; then + debug "Dry run: would stop and remove existing running container '${OPENCODE_WEB_CONTAINER_NAME}' before launch." + else + debug "Dry run: would remove existing stopped container '${OPENCODE_WEB_CONTAINER_NAME}' before launch." + fi + return 0 + fi + + if [ -n "$running_name" ]; then + log "Stopping existing running container '${OPENCODE_WEB_CONTAINER_NAME}' before launch." + docker stop "${OPENCODE_WEB_CONTAINER_NAME}" >/dev/null 2>&1 || die "Failed to stop existing container '${OPENCODE_WEB_CONTAINER_NAME}'." + fi + + log "Removing existing container '${OPENCODE_WEB_CONTAINER_NAME}' before launch." + docker rm "${OPENCODE_WEB_CONTAINER_NAME}" >/dev/null 2>&1 || die "Failed to remove existing container '${OPENCODE_WEB_CONTAINER_NAME}'." +} + +main() { + local mode use_gh mount_ssh use_wrangler + local host_agents_enabled host_agents_source host_agents_path + local host_agents_container_path host_agents_opencode_path + local host_agents_codex_path host_agents_copilot_path host_agents_claude_path + local host_agents_log host_agents_disabled + local gh_host_config_dir + local wrangler_host_config_dir + local runtime_home runtime_xdg_config runtime_xdg_data runtime_xdg_state + local -a passthrough docker_args app_cmd docker_cmd + + mode="run" + use_gh=0 + mount_ssh=0 + use_wrangler=0 + passthrough=() + host_agents_enabled=1 + host_agents_source="" + host_agents_path="" + host_agents_container_path="${OPENCODE_WEB_YOLO_HOME}/.config/opencode/AGENTS.md" + host_agents_opencode_path="$(expand_tilde "${HOME}/.config/opencode/AGENTS.md")" + host_agents_codex_path="$(expand_tilde "${HOME}/.codex/AGENTS.md")" + host_agents_copilot_path="$(expand_tilde "${HOME}/.copilot/copilot-instructions.md")" + host_agents_claude_path="$(expand_tilde "${HOME}/.claude/CLAUDE.md")" + host_agents_log="" + host_agents_disabled=0 + + while [ "$#" -gt 0 ]; do + case "$1" in + --) + shift + passthrough+=("$@") + break + ;; + --pull) + OPENCODE_WEB_BUILD_PULL=1 + ;; + --no-pull) + OPENCODE_WEB_AUTO_PULL=0 + OPENCODE_WEB_BUILD_PULL=0 + ;; + --playwright) + OPENCODE_WEB_BUILD_PLAYWRIGHT=1 + ;; + --wrangler) + OPENCODE_WEB_BUILD_WRANGLER=1 + use_wrangler=1 + ;; + --agents-file=*) + host_agents_enabled=1 + host_agents_source="flag" + host_agents_path="${1#*=}" + ;; + --agents-file) + shift + [ "$#" -gt 0 ] || die "--agents-file requires a host path." + host_agents_enabled=1 + host_agents_source="flag" + host_agents_path="$1" + ;; + --no-host-agents) + host_agents_enabled=0 + host_agents_source="disabled" + host_agents_path="" + host_agents_disabled=1 + ;; + --dry-run) + OPENCODE_WEB_DRY_RUN=1 + ;; + --detach|-d) + OPENCODE_WEB_RUN_DETACHED=1 + ;; + --foreground|-f) + OPENCODE_WEB_RUN_DETACHED=0 + ;; + --mount-ssh) + mount_ssh=1 + ;; + -gh|--gh) + use_gh=1 + ;; + health|--health|diagnostics) + mode="health" + ;; + config) + mode="config" + ;; + --help|-h|help) + mode="help" + ;; + --version|version) + mode="version" + ;; + --verbose|-v) + OPENCODE_WEB_VERBOSE=1 + VERBOSE=1 + ;; + *) + passthrough+=("$1") + ;; + esac + shift + done + + OPENCODE_WEB_BUILD_PULL="$(normalize_bool "${OPENCODE_WEB_BUILD_PULL}")" + OPENCODE_WEB_BUILD_NO_CACHE="$(normalize_bool "${OPENCODE_WEB_BUILD_NO_CACHE}")" + OPENCODE_WEB_BUILD_PLAYWRIGHT="$(normalize_bool "${OPENCODE_WEB_BUILD_PLAYWRIGHT}")" + OPENCODE_WEB_BUILD_WRANGLER="$(normalize_bool "${OPENCODE_WEB_BUILD_WRANGLER}")" + OPENCODE_WEB_AUTO_PULL="$(normalize_bool "${OPENCODE_WEB_AUTO_PULL}")" + OPENCODE_WEB_RUN_DETACHED="$(normalize_bool "${OPENCODE_WEB_RUN_DETACHED}")" + OPENCODE_WEB_SKIP_UPDATE_CHECK="$(normalize_bool "${OPENCODE_WEB_SKIP_UPDATE_CHECK}")" + OPENCODE_WEB_SKIP_VERSION_CHECK="$(normalize_bool "${OPENCODE_WEB_SKIP_VERSION_CHECK}")" + + case "$mode" in + version) + print_version + return 0 + ;; + config) + write_default_config + return 0 + ;; + help) + print_help + return 0 + ;; + health) + show_health + return $? + ;; + esac + + apply_self_update + + if is_true "${OPENCODE_WEB_AUTO_PULL}"; then + OPENCODE_WEB_BUILD_PULL=1 + fi + + require_password + require_command docker + docker info >/dev/null 2>&1 || die "Docker daemon is not available." + [ -n "${OPENCODE_WEB_CONTAINER_NAME}" ] || die "OPENCODE_WEB_CONTAINER_NAME must be non-empty." + [ -n "${OPENCODE_WEB_RESTART_POLICY}" ] || die "OPENCODE_WEB_RESTART_POLICY must be non-empty." + + runtime_home="${OPENCODE_WEB_YOLO_HOME}" + runtime_xdg_config="${OPENCODE_WEB_YOLO_HOME}/.config" + runtime_xdg_data="${OPENCODE_WEB_YOLO_HOME}/.local/share" + runtime_xdg_state="${OPENCODE_WEB_YOLO_HOME}/.local/share/opencode/state" + + mkdir -p "${OPENCODE_WEB_CONFIG_DIR}" "${OPENCODE_WEB_DATA_DIR}" + + docker_args=( + run + --name "${OPENCODE_WEB_CONTAINER_NAME}" + --restart "${OPENCODE_WEB_RESTART_POLICY}" + -p "127.0.0.1:${OPENCODE_WEB_PORT}:${OPENCODE_WEB_PORT}" + -w "${OPENCODE_WEB_YOLO_WORKDIR}" + -e "LOCAL_UID=$(id -u)" + -e "LOCAL_GID=$(id -g)" + -e "LOCAL_USER=$(id -un)" + -e "OPENCODE_WEB_YOLO_CLEANUP=${OPENCODE_WEB_YOLO_CLEANUP}" + -e "OPENCODE_WEB_YOLO_HOME=${OPENCODE_WEB_YOLO_HOME}" + -e "OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD}" + -e "OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME}" + -e "HOME=${runtime_home}" + -e "XDG_CONFIG_HOME=${runtime_xdg_config}" + -e "XDG_DATA_HOME=${runtime_xdg_data}" + -e "XDG_STATE_HOME=${runtime_xdg_state}" + -v "${PWD}:${OPENCODE_WEB_YOLO_WORKDIR}" + -v "${OPENCODE_WEB_CONFIG_DIR}:${OPENCODE_WEB_YOLO_HOME}/.config/opencode" + -v "${OPENCODE_WEB_DATA_DIR}:${OPENCODE_WEB_YOLO_HOME}/.local/share/opencode" + ) + + if is_true "${OPENCODE_WEB_RUN_DETACHED}"; then + docker_args+=(-d) + fi + + if [ "$use_gh" -eq 1 ]; then + require_command gh + if ! gh auth status >/dev/null 2>&1; then + die "The '-gh' flag requires authenticated GitHub CLI on host. Run 'gh auth login' first." + fi + gh_host_config_dir="${XDG_CONFIG_HOME:-${HOME}/.config}/gh" + [ -d "$gh_host_config_dir" ] || die "GitHub CLI config directory not found at ${gh_host_config_dir}." + warn "Mounting host GitHub CLI auth/config into the container. Container processes can use your host GitHub credentials." + docker_args+=(-v "${gh_host_config_dir}:${OPENCODE_WEB_YOLO_HOME}/.config/gh:ro") + fi + + if [ "$mount_ssh" -eq 1 ]; then + [ -d "${HOME}/.ssh" ] || die "--mount-ssh requested but ${HOME}/.ssh does not exist." + warn "Mounting host SSH keys into container as read-only. Prefer least privilege keys and branch protection." + docker_args+=(-v "${HOME}/.ssh:${OPENCODE_WEB_YOLO_HOME}/.ssh:ro") + if [ -f "${HOME}/.gitconfig" ]; then + docker_args+=(-v "${HOME}/.gitconfig:${OPENCODE_WEB_YOLO_HOME}/.gitconfig:ro") + docker_args+=(-e "GIT_CONFIG_GLOBAL=${OPENCODE_WEB_YOLO_HOME}/.gitconfig") + fi + fi + + if [ "$use_wrangler" -eq 1 ]; then + wrangler_host_config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/.wrangler" + [ -d "$wrangler_host_config_dir" ] || die "--wrangler requested but host Wrangler config directory does not exist at ${wrangler_host_config_dir}." + warn "Mounting host Wrangler config read-write. Container processes can read, modify, and rotate your Cloudflare credentials; only use --wrangler with trusted code." + docker_args+=(-v "${wrangler_host_config_dir}:${OPENCODE_WEB_YOLO_HOME}/.config/.wrangler:rw") + fi + + if [ "$host_agents_enabled" -eq 1 ]; then + if [ -z "$host_agents_source" ]; then + if [ -n "${OPENCODE_HOST_AGENTS:-}" ]; then + host_agents_source="env" + host_agents_path="${OPENCODE_HOST_AGENTS}" + elif [ -f "$host_agents_opencode_path" ]; then + host_agents_source="opencode" + host_agents_path="${host_agents_opencode_path}" + elif [ -f "$host_agents_codex_path" ]; then + host_agents_source="codex" + host_agents_path="${host_agents_codex_path}" + elif [ -f "$host_agents_copilot_path" ]; then + host_agents_source="copilot" + host_agents_path="${host_agents_copilot_path}" + elif [ -f "$host_agents_claude_path" ]; then + host_agents_source="claude" + host_agents_path="${host_agents_claude_path}" + else + host_agents_source="none" + fi + fi + + if [ "$host_agents_source" = "flag" ] && [ -z "$host_agents_path" ]; then + die "--agents-file requires a non-empty host path." + fi + + if [ "$host_agents_source" = "none" ]; then + debug "No host instruction file found in default order; relying on project rules and OpenCode defaults." + elif [ -n "$host_agents_path" ]; then + host_agents_path="$(expand_tilde "$host_agents_path")" + if [ -f "$host_agents_path" ]; then + if [ ! -r "$host_agents_path" ]; then + die "Host instruction file is not readable at ${host_agents_path}." + fi + host_agents_log="Using host instruction file from ${host_agents_source}: ${host_agents_path}" + docker_args+=(-v "${host_agents_path}:${host_agents_container_path}:ro") + else + if [ "$host_agents_source" = "flag" ] || [ "$host_agents_source" = "env" ]; then + die "Host instruction file not found at ${host_agents_path}." + fi + debug "Host instruction file disappeared before mount: ${host_agents_path}; continuing without host mount." + fi + fi + else + host_agents_log="Host instruction file mount disabled by --no-host-agents." + fi + + app_cmd=(opencode web --hostname "${OPENCODE_WEB_HOSTNAME}" --port "${OPENCODE_WEB_PORT}") + app_cmd+=("${passthrough[@]}") + + ensure_image + prepare_runtime_container + + docker_cmd=(docker "${docker_args[@]}" "${OPENCODE_WEB_YOLO_IMAGE}" "${app_cmd[@]}") + + if is_true "${OPENCODE_WEB_DRY_RUN}"; then + printf '%s\n' "DRY RUN" + printf '%s\n' "wrapper_version=${WRAPPER_VERSION}" + printf '%s\n' "publish=127.0.0.1:${OPENCODE_WEB_PORT}:${OPENCODE_WEB_PORT}" + printf '%s\n' "hostname=${OPENCODE_WEB_HOSTNAME}" + printf '%s\n' "container_name=${OPENCODE_WEB_CONTAINER_NAME}" + printf '%s\n' "restart_policy=${OPENCODE_WEB_RESTART_POLICY}" + printf '%s\n' "run_detached=${OPENCODE_WEB_RUN_DETACHED}" + printf '%s\n' "auto_pull=${OPENCODE_WEB_AUTO_PULL}" + printf '%s\n' "build_pull=${OPENCODE_WEB_BUILD_PULL}" + printf '%s\n' "build_playwright=${OPENCODE_WEB_BUILD_PLAYWRIGHT}" + printf '%s\n' "build_wrangler=${OPENCODE_WEB_BUILD_WRANGLER}" + printf '%s\n' "opencode_config_dir=${OPENCODE_WEB_CONFIG_DIR}" + printf '%s\n' "opencode_data_dir=${OPENCODE_WEB_DATA_DIR}" + printf '%s\n' "runtime_env_home=${runtime_home}" + printf '%s\n' "runtime_env_xdg_config_home=${runtime_xdg_config}" + printf '%s\n' "runtime_env_xdg_data_home=${runtime_xdg_data}" + printf '%s\n' "runtime_env_xdg_state_home=${runtime_xdg_state}" + printf '%s\n' "command=opencode web --hostname ${OPENCODE_WEB_HOSTNAME} --port ${OPENCODE_WEB_PORT}" + printf '%s\n' "env.OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME}" + printf '%s\n' "host_agents_source=${host_agents_source}" + printf '%s\n' "host_agents_path=${host_agents_path}" + printf '%s\n' "host_agents_disabled=${host_agents_disabled}" + printf '%s\n' "docker_command:" + printf ' ' + printf '%q ' "${docker_cmd[@]}" + printf '\n' + if [ -n "$host_agents_log" ]; then + printf '%s\n' "${host_agents_log}" + fi + return 0 + fi + + if [ -n "$host_agents_log" ]; then + log "${host_agents_log}" + fi + + "${docker_cmd[@]}" +} + +main "$@" diff --git a/tests/test_health.sh b/tests/test_health.sh index eb83896..0b61bf9 100755 --- a/tests/test_health.sh +++ b/tests/test_health.sh @@ -15,6 +15,7 @@ setup_fake_docker "$FAKE_BIN" "$WRAPPER_VERSION" export PATH="${FAKE_BIN}:${PATH}" export HOME="${TMP_DIR}/home" unset XDG_CONFIG_HOME XDG_DATA_HOME || true +unset OPENCODE_WEB_RETENTION_DAYS OPENCODE_WEB_RETENTION_POLL_SECONDS OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS || true mkdir -p "${HOME}" export OPENCODE_WEB_SKIP_UPDATE_CHECK=1 diff --git a/tests/test_helpers.sh b/tests/test_helpers.sh index e88bdfe..0e2312b 100755 --- a/tests/test_helpers.sh +++ b/tests/test_helpers.sh @@ -38,21 +38,7 @@ assert_file_executable() { } managed_wrapper_files() { - cat <<'EOF' -.opencode_web_yolo.sh -.opencode_web_yolo_config.sh -.opencode_web_yolo.Dockerfile -.opencode_web_yolo_entrypoint.sh -.opencode_web_yolo_runtime.sh -.opencode_web_yolo_retention.js -.opencode_web_yolo_completion.bash -.opencode_web_yolo_completion.zsh -install.sh -VERSION -CHANGELOG.md -README.md -TECHNICAL.md -EOF + cat "${ROOT_DIR}/.opencode_web_yolo.manifest" } create_managed_install_home() { @@ -145,6 +131,51 @@ if [ -n "$log_file" ]; then printf '%s\n' "$url" >>"$log_file" fi +if [[ "$url" == https://github.com/*/archive/refs/heads/*.tar.gz ]]; then + if [ "${OPENCODE_WEB_TEST_CURL_FAIL_ON:-}" = "archive" ]; then + printf '%s\n' "simulated curl failure for release archive" >&2 + exit 1 + fi + if [ "${OPENCODE_WEB_TEST_ARCHIVE_MODE:-}" = "malformed" ]; then + printf '%s\n' "not a tar archive" >"$4" + exit 0 + fi + archive_dir="$(mktemp -d)" + mkdir -p "${archive_dir}/release-root" + while IFS= read -r archive_file; do + if [ "${OPENCODE_WEB_TEST_ARCHIVE_MISSING:-}" = "$archive_file" ]; then + continue + fi + mkdir -p "$(dirname "${archive_dir}/release-root/${archive_file}")" + cp -p "${remote_dir}/${archive_file}" "${archive_dir}/release-root/${archive_file}" + done <"${remote_dir}/.opencode_web_yolo.manifest" + case "${OPENCODE_WEB_TEST_ARCHIVE_MODE:-}" in + traversal) + mkdir -p "${archive_dir}/outside" + printf '%s\n' traversal >"${archive_dir}/outside/escape" + tar -czf "$4" -C "$archive_dir" --transform='s#^outside/escape#release-root/../escape#' release-root outside/escape + ;; + multi-root) + mkdir -p "${archive_dir}/other-root" + printf '%s\n' second-root >"${archive_dir}/other-root/extra" + tar -czf "$4" -C "$archive_dir" release-root other-root + ;; + symlink) + ln -s VERSION "${archive_dir}/release-root/unsafe-link" + tar -czf "$4" -C "$archive_dir" release-root + ;; + hardlink) + ln "${archive_dir}/release-root/VERSION" "${archive_dir}/release-root/unsafe-hardlink" + tar -czf "$4" -C "$archive_dir" release-root + ;; + *) + tar -czf "$4" -C "$archive_dir" release-root + ;; + esac + rm -rf "$archive_dir" + exit 0 +fi + if [ -z "$remote_dir" ]; then printf '%s\n' "missing OPENCODE_WEB_TEST_REMOTE_DIR" >&2 exit 1 diff --git a/tests/test_install_bootstrap.sh b/tests/test_install_bootstrap.sh index 95c1f4a..c887712 100755 --- a/tests/test_install_bootstrap.sh +++ b/tests/test_install_bootstrap.sh @@ -17,23 +17,7 @@ install_log="${work_dir}/install.log" mkdir -p "$home_dir" "$fake_bin" "$remote_dir" -required_files=( - ".opencode_web_yolo.sh" - ".opencode_web_yolo_config.sh" - ".opencode_web_yolo.Dockerfile" - ".opencode_web_yolo_entrypoint.sh" - ".opencode_web_yolo_runtime.sh" - ".opencode_web_yolo_retention.js" - ".opencode_web_yolo_completion.bash" - ".opencode_web_yolo_completion.zsh" - "install.sh" - "VERSION" - "CHANGELOG.md" - "README.md" - "TECHNICAL.md" - "LICENSE" - "CODEOWNERS" -) +mapfile -t required_files <"${ROOT_DIR}/.opencode_web_yolo.manifest" for required_file in "${required_files[@]}"; do cp "${ROOT_DIR}/${required_file}" "${remote_dir}/${required_file}" @@ -49,10 +33,49 @@ if [ "$#" -lt 4 ] || [ "$1" != "-fsSL" ] || [ "$3" != "-o" ]; then fi url="$2" -destination="$4" -file_name="${url##*/}" remote_dir="${OPENCODE_WEB_TEST_REMOTE_DIR:?}" +if [[ "$url" == https://github.com/*/archive/refs/heads/*.tar.gz ]]; then + destination="$4" + archive_dir="$(mktemp -d)" + mkdir -p "${archive_dir}/release-root" + while IFS= read -r archive_file; do + mkdir -p "$(dirname "${archive_dir}/release-root/${archive_file}")" + cp -p "${remote_dir}/${archive_file}" "${archive_dir}/release-root/${archive_file}" + done <"${remote_dir}/.opencode_web_yolo.manifest" + case "${OPENCODE_WEB_TEST_ARCHIVE_MODE:-}" in + traversal) + mkdir -p "${archive_dir}/outside" + printf '%s\n' traversal >"${archive_dir}/outside/escape" + tar -czf "$destination" -C "$archive_dir" --transform='s#^outside/escape#release-root/../escape#' release-root outside/escape + ;; + multi-root) + mkdir -p "${archive_dir}/other-root" + printf '%s\n' second-root >"${archive_dir}/other-root/extra" + tar -czf "$destination" -C "$archive_dir" release-root other-root + ;; + symlink) + ln -s VERSION "${archive_dir}/release-root/unsafe-link" + tar -czf "$destination" -C "$archive_dir" release-root + ;; + hardlink) + ln "${archive_dir}/release-root/VERSION" "${archive_dir}/release-root/unsafe-hardlink" + tar -czf "$destination" -C "$archive_dir" release-root + ;; + duplicate-manifest) + printf '%s\n' ".opencode_web_yolo_runtime.sh" >>"${archive_dir}/release-root/.opencode_web_yolo.manifest" + tar -czf "$destination" -C "$archive_dir" release-root + ;; + *) + tar -czf "$destination" -C "$archive_dir" release-root + ;; + esac + rm -rf "$archive_dir" + exit 0 +fi + +file_name="${url##*/}" + if [ ! -f "${remote_dir}/${file_name}" ]; then printf '%s\n' "missing test remote file: ${file_name}" >&2 exit 1 @@ -69,7 +92,7 @@ OPENCODE_WEB_BIN_DIR="$bin_dir" \ OPENCODE_WEB_YOLO_REPO="example/repo" \ OPENCODE_WEB_YOLO_BRANCH="main" \ OPENCODE_WEB_TEST_REMOTE_DIR="$remote_dir" \ -bash <"${ROOT_DIR}/install.sh" >"$install_log" +bash <"${ROOT_DIR}/install.sh" >"$install_log" 2>&1 if [ ! -L "${bin_dir}/opencode_web_yolo" ]; then fail "expected opencode_web_yolo symlink to be installed" @@ -89,4 +112,27 @@ if ! grep -F "[install] Fetching install assets from example/repo@main" "$instal fail "expected bootstrap fetch log line" fi +for archive_mode in traversal symlink hardlink multi-root duplicate-manifest; do + bad_install_home="${work_dir}/bad-${archive_mode}" + rm -rf "$bad_install_home" + set +e + PATH="${fake_bin}:${PATH}" \ + HOME="$home_dir" \ + OPENCODE_WEB_INSTALL_HOME="$bad_install_home" \ + OPENCODE_WEB_BIN_DIR="${bin_dir}" \ + OPENCODE_WEB_YOLO_REPO="example/repo" \ + OPENCODE_WEB_YOLO_BRANCH="main" \ + OPENCODE_WEB_TEST_REMOTE_DIR="$remote_dir" \ + OPENCODE_WEB_TEST_ARCHIVE_MODE="$archive_mode" \ + bash <"${ROOT_DIR}/install.sh" >"${install_log}" 2>&1 + status=$? + set -e + if [ "$status" -eq 0 ]; then + fail "expected bootstrap ${archive_mode} archive rejection" + fi + if [ -e "${bad_install_home}/VERSION" ]; then + fail "rejected bootstrap ${archive_mode} archive must not install VERSION" + fi +done + printf '%s\n' "PASS: streamed install bootstrap" diff --git a/tests/test_self_update.sh b/tests/test_self_update.sh index 0a923f0..2d3e8f3 100644 --- a/tests/test_self_update.sh +++ b/tests/test_self_update.sh @@ -14,12 +14,33 @@ REMOTE_DIR="${TMP_DIR}/remote" HOME_DIR="${TMP_DIR}/home" CURL_LOG="${TMP_DIR}/curl.log" LOCAL_VERSION="$(tr -d '[:space:]' <"${ROOT_DIR}/VERSION")" -REMOTE_VERSION="9.9.9" +REMOTE_VERSION="0.10.0" +CURRENT_RELEASE_VERSION="0.2.2" mkdir -p "$FAKE_BIN" "$HOME_DIR" setup_fake_docker "$FAKE_BIN" "$LOCAL_VERSION" setup_fake_curl "$FAKE_BIN" +unset OPENCODE_WEB_UPDATE_REEXECED OPENCODE_WEB_RETENTION_DAYS OPENCODE_WEB_RETENTION_DRY_RUN \ + OPENCODE_WEB_RETENTION_POLL_SECONDS OPENCODE_WEB_RETENTION_FETCH_TIMEOUT_MS \ + OPENCODE_WEB_RETENTION_VERIFY_TIMEOUT_MS OPENCODE_WEB_CONFIG_FILE OPENCODE_WEB_CONFIG_DIR \ + OPENCODE_WEB_DATA_DIR OPENCODE_WEB_YOLO_CONFIG_FILE OPENCODE_WEB_YOLO_HOME \ + OPENCODE_WEB_YOLO_WORKDIR OPENCODE_WEB_PORT OPENCODE_WEB_HOSTNAME OPENCODE_WEB_YOLO_IMAGE \ + OPENCODE_WEB_BASE_IMAGE OPENCODE_WEB_CONTAINER_NAME OPENCODE_WEB_RESTART_POLICY \ + OPENCODE_WEB_AUTO_PULL OPENCODE_WEB_RUN_DETACHED OPENCODE_WEB_DRY_RUN OPENCODE_WEB_BUILD_PULL \ + OPENCODE_WEB_BUILD_NO_CACHE OPENCODE_WEB_BUILD_PLAYWRIGHT OPENCODE_WEB_BUILD_WRANGLER \ + OPENCODE_WEB_EXPECTED_OPENCODE_VERSION OPENCODE_WEB_EXPECTED_PLAYWRIGHT_VERSION \ + OPENCODE_WEB_NPM_PACKAGE OPENCODE_SERVER_USERNAME || true + +if grep -F 'sort -V' "${ROOT_DIR}/.opencode_web_yolo.sh" >/dev/null 2>&1; then + fail "version comparison must not depend on sort -V" +fi +for production_file in "${ROOT_DIR}/.opencode_web_yolo.sh" "${ROOT_DIR}/install.sh"; do + if grep -Eq 'local -A|declare -A|mapfile|readarray' "$production_file"; then + fail "${production_file} contains a Bash 4-only construct" + fi +done + export PATH="${FAKE_BIN}:${PATH}" export HOME="${HOME_DIR}" export OPENCODE_SERVER_PASSWORD="secret" @@ -34,6 +55,31 @@ reset_install_home() { create_managed_install_home "$ROOT_DIR" "$INSTALL_HOME" } +create_old_install_home() { + local old_file + rm -rf "${INSTALL_HOME}" + mkdir -p "${INSTALL_HOME}" + while IFS= read -r old_file; do + mkdir -p "$(dirname "${INSTALL_HOME}/${old_file}")" + cp "${ROOT_DIR}/${old_file}" "${INSTALL_HOME}/${old_file}" + done <<'EOF' +.opencode_web_yolo_config.sh +.opencode_web_yolo.Dockerfile +.opencode_web_yolo_entrypoint.sh +CHANGELOG.md +README.md +TECHNICAL.md +EOF + cp "${ROOT_DIR}/tests/fixtures/old-0.1.10/.opencode_web_yolo.sh" "${INSTALL_HOME}/.opencode_web_yolo.sh" + cp "${ROOT_DIR}/install.sh" "${INSTALL_HOME}/install.sh" + printf '%s\n' '0.1.10' >"${INSTALL_HOME}/VERSION" + assert_equals "3340ea78cbadfbcc3f436a0ba5822765813ff401" \ + "$(git hash-object "${ROOT_DIR}/tests/fixtures/old-0.1.10/.opencode_web_yolo.sh")" + chmod +x "${INSTALL_HOME}/.opencode_web_yolo.sh" + chmod +x "${INSTALL_HOME}/.opencode_web_yolo_entrypoint.sh" + chmod +x "${INSTALL_HOME}/install.sh" +} + prepare_remote_release() { local version="$1" local readme_marker="$2" @@ -49,6 +95,26 @@ prepare_remote_release() { printf '%s\n' "remote-${readme_marker}" >"${REMOTE_DIR}/README.md" } +assert_malicious_archive_rejected() { + local archive_mode="$1" + local output status + + reset_install_home + prepare_remote_release "${LOCAL_VERSION}" "malicious-${archive_mode}" + rm -f "${INSTALL_HOME}/.opencode_web_yolo_runtime.sh" "${CURL_LOG}" + export OPENCODE_WEB_TEST_ARCHIVE_MODE="$archive_mode" + set +e + output="$("${INSTALL_HOME}/.opencode_web_yolo.sh" --dry-run 2>&1)" + status=$? + set -e + unset OPENCODE_WEB_TEST_ARCHIVE_MODE + if [ "$status" -eq 0 ]; then + fail "expected ${archive_mode} archive rejection" + fi + assert_contains "$output" "malformed or truncated" + assert_equals "${LOCAL_VERSION}" "$(tr -d '[:space:]' <"${INSTALL_HOME}/VERSION")" +} + reset_install_home prepare_remote_release "${REMOTE_VERSION}" "skip" rm -f "${CURL_LOG}" @@ -79,6 +145,7 @@ assert_not_contains "$output_same" "Updating wrapper from" same_version_calls="$(cat "${CURL_LOG}")" assert_contains "$same_version_calls" "/VERSION" assert_not_contains "$same_version_calls" "/README.md" +assert_not_contains "$same_version_calls" "/archive/refs/heads/" if grep -F -- "remote-same-version" "${INSTALL_HOME}/README.md" >/dev/null 2>&1; then fail "expected same-version update check to leave managed files untouched" fi @@ -103,14 +170,142 @@ assert_file_executable "${INSTALL_HOME}/.opencode_web_yolo.sh" assert_file_executable "${INSTALL_HOME}/.opencode_web_yolo_entrypoint.sh" assert_file_executable "${INSTALL_HOME}/install.sh" update_calls="$(cat "${CURL_LOG}")" -while IFS= read -r managed_file; do - assert_contains "$update_calls" "/${managed_file}" -done < <(managed_wrapper_files) +assert_contains "$update_calls" "/VERSION" +assert_contains "$update_calls" "/archive/refs/heads/main.tar.gz" + +reset_install_home +prepare_remote_release "${LOCAL_VERSION}" "repair" +rm -f "${INSTALL_HOME}/.opencode_web_yolo_runtime.sh" "${CURL_LOG}" +output_repair="$("${INSTALL_HOME}/.opencode_web_yolo.sh" --dry-run 2>&1)" +assert_contains "$output_repair" "Repairing incomplete managed install at version ${LOCAL_VERSION}." +assert_contains "$output_repair" "DRY RUN" +if [ ! -s "${INSTALL_HOME}/.opencode_web_yolo_runtime.sh" ] || [ ! -s "${INSTALL_HOME}/.opencode_web_yolo_retention.js" ]; then + fail "expected equal-version repair to restore runtime helpers" +fi +assert_equals "${LOCAL_VERSION}" "$(tr -d '[:space:]' <"${INSTALL_HOME}/VERSION")" +assert_contains "$(cat "${CURL_LOG}")" "/archive/refs/heads/main.tar.gz" + +reset_install_home +prepare_remote_release "${LOCAL_VERSION}" "encoded-branch" +rm -f "${INSTALL_HOME}/.opencode_web_yolo_runtime.sh" "${CURL_LOG}" +export OPENCODE_WEB_YOLO_BRANCH="feature/release candidate" +output_encoded_branch="$("${INSTALL_HOME}/.opencode_web_yolo.sh" --dry-run 2>&1)" +assert_contains "$output_encoded_branch" "DRY RUN" +assert_contains "$(cat "${CURL_LOG}")" "/archive/refs/heads/feature/release%20candidate.tar.gz" +export OPENCODE_WEB_YOLO_BRANCH="main" + +assert_malicious_archive_rejected traversal +assert_malicious_archive_rejected symlink +assert_malicious_archive_rejected hardlink +assert_malicious_archive_rejected multi-root + +reset_install_home +prepare_remote_release "${LOCAL_VERSION}" "malformed" +rm -f "${INSTALL_HOME}/.opencode_web_yolo_runtime.sh" "${CURL_LOG}" +export OPENCODE_WEB_TEST_ARCHIVE_MODE=malformed +set +e +output_malformed="$("${INSTALL_HOME}/.opencode_web_yolo.sh" --dry-run 2>&1)" +status=$? +set -e +unset OPENCODE_WEB_TEST_ARCHIVE_MODE +if [ "$status" -eq 0 ]; then + fail "expected malformed archive rejection" +fi +assert_contains "$output_malformed" "malformed or truncated" +assert_equals "${LOCAL_VERSION}" "$(tr -d '[:space:]' <"${INSTALL_HOME}/VERSION")" +if [ -s "${INSTALL_HOME}/README.md" ] && grep -F -- "remote-malformed" "${INSTALL_HOME}/README.md" >/dev/null 2>&1; then + fail "malformed archive must not advance or partially promote the install" +fi + +reset_install_home +prepare_remote_release "${LOCAL_VERSION}" "missing-runtime" +rm -f "${INSTALL_HOME}/.opencode_web_yolo_runtime.sh" "${CURL_LOG}" +export OPENCODE_WEB_TEST_ARCHIVE_MISSING=.opencode_web_yolo_runtime.sh +set +e +output_missing="$("${INSTALL_HOME}/.opencode_web_yolo.sh" --dry-run 2>&1)" +status=$? +set -e +unset OPENCODE_WEB_TEST_ARCHIVE_MISSING +if [ "$status" -eq 0 ]; then + fail "expected archive missing-file rejection" +fi +assert_contains "$output_missing" "missing, empty, or contains invalid managed files" +assert_equals "${LOCAL_VERSION}" "$(tr -d '[:space:]' <"${INSTALL_HOME}/VERSION")" + +reset_install_home +prepare_remote_release "${REMOTE_VERSION}" "promotion-interruption" +rm -f "${CURL_LOG}" +export OPENCODE_WEB_YOLO_TEST_FAIL_PROMOTION_ON=.opencode_web_yolo_runtime.sh +set +e +output_interrupted="$("${INSTALL_HOME}/.opencode_web_yolo.sh" --dry-run 2>&1)" +status=$? +set -e +unset OPENCODE_WEB_YOLO_TEST_FAIL_PROMOTION_ON +if [ "$status" -eq 0 ]; then + fail "expected simulated promotion interruption" +fi +assert_contains "$output_interrupted" "Test promotion interruption requested" +assert_equals "${LOCAL_VERSION}" "$(tr -d '[:space:]' <"${INSTALL_HOME}/VERSION")" +if grep -F -- "remote-promotion-interruption" "${INSTALL_HOME}/README.md" >/dev/null 2>&1; then + fail "interrupted promotion must not advance the managed release" +fi +output_retry="$("${INSTALL_HOME}/.opencode_web_yolo.sh" --dry-run 2>&1)" +assert_contains "$output_retry" "Update complete, re-executing wrapper." +assert_equals "${REMOTE_VERSION}" "$(tr -d '[:space:]' <"${INSTALL_HOME}/VERSION")" + +reset_install_home +prepare_remote_release "${REMOTE_VERSION}" "wrapper-before-version" +printf '%s\n' '# test-after-wrapper-promotion' >>"${REMOTE_DIR}/.opencode_web_yolo.sh" +rm -f "${CURL_LOG}" +export OPENCODE_WEB_YOLO_TEST_FAIL_PROMOTION_ON=after-wrapper +set +e +output_after_wrapper="$("${INSTALL_HOME}/.opencode_web_yolo.sh" --dry-run 2>&1)" +status=$? +set -e +unset OPENCODE_WEB_YOLO_TEST_FAIL_PROMOTION_ON +if [ "$status" -eq 0 ]; then + fail "expected simulated post-wrapper promotion interruption" +fi +assert_contains "$output_after_wrapper" "after wrapper promotion" +assert_equals "${LOCAL_VERSION}" "$(tr -d '[:space:]' <"${INSTALL_HOME}/VERSION")" +assert_contains "$(cat "${INSTALL_HOME}/.opencode_web_yolo.sh")" "test-after-wrapper-promotion" +output_after_wrapper_retry="$("${INSTALL_HOME}/.opencode_web_yolo.sh" --dry-run 2>&1)" +assert_contains "$output_after_wrapper_retry" "Update complete, re-executing wrapper." +assert_equals "${REMOTE_VERSION}" "$(tr -d '[:space:]' <"${INSTALL_HOME}/VERSION")" + +reset_install_home +prepare_remote_release "${LOCAL_VERSION}" "duplicate-manifest" +printf '%s\n' ".opencode_web_yolo_runtime.sh" >>"${REMOTE_DIR}/.opencode_web_yolo.manifest" +rm -f "${INSTALL_HOME}/.opencode_web_yolo_runtime.sh" "${CURL_LOG}" +set +e +output_duplicate="$("${INSTALL_HOME}/.opencode_web_yolo.sh" --dry-run 2>&1)" +status=$? +set -e +if [ "$status" -eq 0 ]; then + fail "expected duplicate manifest rejection" +fi +assert_contains "$output_duplicate" "missing, empty, or contains invalid managed files" +assert_equals "${LOCAL_VERSION}" "$(tr -d '[:space:]' <"${INSTALL_HOME}/VERSION")" + +create_old_install_home +prepare_remote_release "${CURRENT_RELEASE_VERSION}" "old-wrapper-repair" +rm -f "${CURL_LOG}" +assert_equals "0.1.10" "$(tr -d '[:space:]' <"${INSTALL_HOME}/VERSION")" +output_old="$("${INSTALL_HOME}/.opencode_web_yolo.sh" --dry-run 2>&1)" +assert_contains "$output_old" "DRY RUN" +if [ ! -s "${INSTALL_HOME}/.opencode_web_yolo_runtime.sh" ] || [ ! -s "${INSTALL_HOME}/.opencode_web_yolo_retention.js" ]; then + fail "historical wrapper update did not repair newly added runtime helpers" +fi +if [ ! -s "${INSTALL_HOME}/.opencode_web_yolo.manifest" ]; then + fail "historical wrapper update did not install the managed-file manifest" +fi +assert_equals "${CURRENT_RELEASE_VERSION}" "$(tr -d '[:space:]' <"${INSTALL_HOME}/VERSION")" +assert_contains "$(cat "${CURL_LOG}")" "/archive/refs/heads/main.tar.gz" reset_install_home prepare_remote_release "${REMOTE_VERSION}" "failure" rm -f "${CURL_LOG}" -export OPENCODE_WEB_TEST_CURL_FAIL_ON="install.sh" +export OPENCODE_WEB_TEST_CURL_FAIL_ON="archive" set +e output_failure="$("${INSTALL_HOME}/.opencode_web_yolo.sh" --dry-run 2>&1)" status=$? @@ -119,7 +314,7 @@ unset OPENCODE_WEB_TEST_CURL_FAIL_ON if [ "$status" -eq 0 ]; then fail "expected self-update download failure to exit non-zero" fi -assert_contains "$output_failure" "Failed downloading 'install.sh' during self-update." +assert_contains "$output_failure" "Failed downloading release archive" assert_not_contains "$output_failure" "DRY RUN" assert_equals "${LOCAL_VERSION}" "$(tr -d '[:space:]' <"${INSTALL_HOME}/VERSION")" if grep -F -- "remote-failure" "${INSTALL_HOME}/README.md" >/dev/null 2>&1; then diff --git a/tests/version_guard.sh b/tests/version_guard.sh index 5b825bf..0f40a31 100755 --- a/tests/version_guard.sh +++ b/tests/version_guard.sh @@ -15,6 +15,7 @@ if ! git rev-parse --verify HEAD^ >/dev/null 2>&1; then fi changed_runtime="$(git diff --name-only HEAD^ HEAD -- \ + .opencode_web_yolo.manifest \ .opencode_web_yolo.sh \ .opencode_web_yolo_config.sh \ .opencode_web_yolo.Dockerfile \ From 753d4fcba53b3be89051915d72a279ca01a1b054 Mon Sep 17 00:00:00 2001 From: laurenceputra Date: Wed, 2 Sep 2026 09:25:40 +0000 Subject: [PATCH 2/4] ci: skip linting immutable update fixture --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 92990ea..da81ec2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: shellcheck .opencode_web_yolo_entrypoint.sh shellcheck .opencode_web_yolo_runtime.sh shellcheck install.sh - shellcheck tests/fixtures/old-0.1.10/.opencode_web_yolo.sh + # Immutable archived fixture: its historical sourced config is intentionally absent. shellcheck -x tests/test_helpers.sh shellcheck -x tests/test_dry_run.sh shellcheck -x tests/test_build_expected_version.sh From 8e242a7a7bee3b74303b805024f424f19ad3ca64 Mon Sep 17 00:00:00 2001 From: laurenceputra Date: Wed, 2 Sep 2026 09:27:37 +0000 Subject: [PATCH 3/4] test: clarify shared helper root --- tests/test_helpers.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_helpers.sh b/tests/test_helpers.sh index 0e2312b..9b56d2c 100755 --- a/tests/test_helpers.sh +++ b/tests/test_helpers.sh @@ -38,6 +38,7 @@ assert_file_executable() { } managed_wrapper_files() { + # shellcheck disable=SC2153 # ROOT_DIR is defined by each test before sourcing helpers. cat "${ROOT_DIR}/.opencode_web_yolo.manifest" } From 5c4923c5d94a498ea19b9ae69bda4bc41036f28f Mon Sep 17 00:00:00 2001 From: laurenceputra Date: Wed, 2 Sep 2026 22:07:01 +0000 Subject: [PATCH 4/4] refactor: remove unused managed files wrapper --- .opencode_web_yolo.sh | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.opencode_web_yolo.sh b/.opencode_web_yolo.sh index 64e45d5..b9902df 100755 --- a/.opencode_web_yolo.sh +++ b/.opencode_web_yolo.sh @@ -227,10 +227,6 @@ managed_files_for_dir() { fi } -managed_files() { - managed_files_for_dir "$SCRIPT_DIR" -} - validate_managed_tree() { local source_dir="$1" manifest_file required_file required_path version