fix(upgrade): reconstruct the docker run for non-compose containers

`ai-memory upgrade` could not recreate a container started with plain
`docker run`, so it told the operator to stop and remove it and rebuild
the command from memory — losing ports, mounts and environment in the
process. The old comment claimed the original args were unknowable; they
are not, `docker inspect` has them.

Reconstructs that container's own stop/remove/run — name, restart policy,
published ports, mounts, operator-set environment, and an overridden
command — and writes it for review before it is run.

Written to a 0600 file rather than echoed: a real install's environment
carries provider API keys and AI_MEMORY_AUTH_TOKEN, and printing those
into terminal scrollback or a piped install log is the kind of leak this
project exists to prevent. Environment already baked into the image is
omitted, so the new image's defaults are not frozen to the old ones.

Verified round-trip against live containers: the generated command
recreates an identical container, including an environment value
containing a space, a double quote and a `$`.

Refs #407

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AkitaOnRails
2026-08-18 12:48:18 -03:00
co-authored by Claude Opus 5
parent b3d1c222d5
commit 789b65e196
2 changed files with 132 additions and 4 deletions
+31
View File
@@ -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
+101 -4
View File
@@ -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 <<ENVEOF
${container_env}
ENVEOF
( umask 077 && : > "${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