diff --git a/CHANGELOG.md b/CHANGELOG.md index 95e22858..2ced146a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.28.0] - 2026-08-17 ### Fixed +- Docker containers no longer refuse to start unauthenticated, which had left + every container from the README Quick start crash-looping since v1.27.0 + (#407). The v1.27.0 bind guard reads a non-loopback bind as evidence of + network exposure and refuses without a token. That inference holds on a + host, but not inside a container: publishing a port with `-p` *requires* + binding `0.0.0.0` in the namespace, and whether that port reaches the + network is decided by the host-side publish spec — `-p 127.0.0.1:49374:49374` + versus `-p 0.0.0.0:49374:49374` — which the process cannot observe. The + documented Quick start passes no token and published to loopback, so it was + safe and refused anyway; with the documented `--restart unless-stopped` that + became a restart loop. Containers now log a loud warning naming the publish + spec as the thing to check, instead of refusing. **The host rule is + unchanged** — an unauthenticated non-loopback bind outside a container is + still refused. Containers are detected via `/.dockerenv` (Docker), + `/run/.containerenv` (Podman), or `AI_MEMORY_IN_CONTAINER`, which the + official image now sets. + + Note the `Host` allowlist does not substitute for a token here: it defends + against DNS rebinding, where a *browser* sets the header. A client that can + route to the port sets `Host` freely. If you publish ai-memory beyond + loopback, set `AI_MEMORY_AUTH_TOKEN`. +- `ai-memory upgrade` no longer tells non-compose Docker users to delete their + container and rebuild the `docker run` from memory (#407). It now reconstructs + that container's own stop/remove/run — name, restart policy, published ports, + mounts, operator-set environment, and an overridden command — and writes it to + `${XDG_CACHE_HOME:-~/.cache}/ai-memory/recreate-ai-memory.sh` for review before + you run it. The script is written mode 0600 and never echoed, because a real + install's environment carries provider API keys and `AI_MEMORY_AUTH_TOKEN`. + Environment already baked into the image is deliberately omitted, so the new + image's own defaults are not frozen to the old ones. Compose-based installs + are unaffected and still upgrade via `docker compose up -d`. - `install-hooks --agent codex` and `uninstall` now honor `CODEX_HOME`, instead of always writing to `~/.codex/hooks.json`. Codex loads hooks from its configured home, so on an install with `CODEX_HOME` set the hooks landed diff --git a/bin/ai-memory b/bin/ai-memory index c5e77ca0..00c52f51 100755 --- a/bin/ai-memory +++ b/bin/ai-memory @@ -139,6 +139,91 @@ self_upgrade_script() { AI_MEMORY_SKIP_SELF_UPGRADE=1 exec "${script_path}" upgrade } + +# Reconstruct the `docker run` that created a running container, so a +# non-compose install can be recreated on the freshly pulled image without +# the operator having to remember their original flags (issue #407). +# +# Written to a 0600 file rather than echoed: the environment of a real +# install carries provider API keys and AI_MEMORY_AUTH_TOKEN, and printing +# those into terminal scrollback — or into a piped install log — is exactly +# the kind of leak this project exists to prevent. +# +# Reconstructs the flags that matter for an ai-memory container: name, +# restart policy, published ports, mounts, operator-supplied environment, +# and an overridden command. It does NOT reproduce every possible `docker +# run` flag (networks, capabilities, resource limits, devices); the script +# says so in its own header so a reader can add anything exotic back. +emit_docker_run_script() { + local container="$1" out="$2" image + image="$("${DOCKER}" inspect "${container}" --format '{{.Config.Image}}' 2>/dev/null)" || return 1 + [ -n "${image}" ] || return 1 + + local ports volumes restart cmd + ports="$("${DOCKER}" inspect "${container}" --format \ + '{{range $p, $bindings := .HostConfig.PortBindings}}{{range $bindings}}-p {{if .HostIp}}{{.HostIp}}:{{end}}{{.HostPort}}:{{$p}} {{end}}{{end}}' 2>/dev/null)" + volumes="$("${DOCKER}" inspect "${container}" --format \ + '{{range .Mounts}}{{if eq .Type "volume"}}-v {{.Name}}:{{.Destination}} {{else}}-v {{.Source}}:{{.Destination}} {{end}}{{end}}' 2>/dev/null)" + restart="$("${DOCKER}" inspect "${container}" --format \ + '{{with .HostConfig.RestartPolicy.Name}}{{if ne . "no"}}--restart {{.}}{{end}}{{end}}' 2>/dev/null)" + + # Only the command if the operator overrode the image default; otherwise + # let the new image supply its own (that is the point of upgrading). + local image_cmd container_cmd + image_cmd="$("${DOCKER}" inspect "${image}" --format '{{json .Config.Cmd}}' 2>/dev/null)" + container_cmd="$("${DOCKER}" inspect "${container}" --format '{{json .Config.Cmd}}' 2>/dev/null)" + cmd="" + if [ "${image_cmd}" != "${container_cmd}" ]; then + cmd="$("${DOCKER}" inspect "${container}" --format \ + '{{range .Config.Cmd}}{{printf "%q " .}}{{end}}' 2>/dev/null)" + fi + + # Likewise, carry only environment the operator actually passed. Anything + # baked into the image is re-applied by the new image anyway, and pinning + # it here would freeze a value this upgrade is meant to move forward. + local image_env container_env env_flags="" + image_env="$("${DOCKER}" inspect "${image}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null)" + container_env="$("${DOCKER}" inspect "${container}" --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null)" + local kv + while IFS= read -r kv; do + [ -n "${kv}" ] || continue + if printf '%s\n' "${image_env}" | grep -qxF -- "${kv}"; then + continue + fi + env_flags="${env_flags} -e $(printf '%q' "${kv}")" + done < "${out}" ) || return 1 + { + echo "#!/usr/bin/env bash" + echo "# Reconstructed by \`ai-memory upgrade\` from the running '${container}'" + echo "# container, before it was removed. Review it, then run it." + echo "#" + echo "# Contains this install's environment (API keys, auth token) — it is" + echo "# mode 0600 for that reason. Delete it once the container is back up." + echo "#" + echo "# Covers name, restart policy, ports, mounts, operator-set environment" + echo "# and an overridden command. If your original command used anything" + echo "# else (custom network, capabilities, resource limits), re-add it." + echo "set -euo pipefail" + echo + echo "docker stop ${container}" + echo "docker rm ${container}" + printf 'docker run -d --name %s' "${container}" + [ -n "${restart}" ] && printf ' %s' "${restart}" + [ -n "${ports}" ] && printf ' %s' "${ports% }" + [ -n "${volumes}" ] && printf ' %s' "${volumes% }" + [ -n "${env_flags}" ] && printf '%s' "${env_flags}" + printf ' %s' "${image}" + [ -n "${cmd}" ] && printf ' %s' "${cmd% }" + printf '\n' + } >> "${out}" + chmod 600 "${out}" 2>/dev/null || true + return 0 +} + cmd_upgrade() { if [ -z "${AI_MEMORY_SKIP_SELF_UPGRADE:-}" ]; then self_upgrade_script @@ -206,10 +291,22 @@ cmd_upgrade() { else echo "→ a local ai-memory container is running but no compose file" echo " was found in \$PWD/docker-compose.yml, ./docker/docker-compose.yml," - echo " or ~/deploy/ai-memory/docker-compose.yml. Restart it manually so" - echo " the new image takes effect:" - echo " docker stop ai-memory && docker rm ai-memory" - echo " # then re-run your docker-run command from the README Quick start" + echo " or ~/deploy/ai-memory/docker-compose.yml." + local recreate_script="${CACHE_DIR}/recreate-ai-memory.sh" + mkdir -p "${CACHE_DIR}" 2>/dev/null || true + if emit_docker_run_script "ai-memory" "${recreate_script}"; then + echo " Wrote the equivalent stop/remove/run for THIS container to:" + echo " ${recreate_script}" + echo " Review it and run it so the new image takes effect:" + echo " less ${recreate_script} && bash ${recreate_script}" + echo " (mode 0600 — it carries the environment of this install, including" + echo " any API keys and AI_MEMORY_AUTH_TOKEN. Delete it once you are up.)" + else + echo " Could not inspect the running container to reconstruct its" + echo " command. Restart it manually so the new image takes effect:" + echo " docker stop ai-memory && docker rm ai-memory" + echo " # then re-run your docker-run command from the README Quick start" + fi fi fi