diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base index 23ad9813..03093569 100644 --- a/docker/Dockerfile.base +++ b/docker/Dockerfile.base @@ -33,7 +33,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ && rm -rf /var/lib/apt/lists/* # Copy the huskies binary and entrypoint from the main image. -COPY --from=huskies-src /usr/local/bin/huskies /usr/local/bin/huskies +# The binary lives in /opt/huskies/bin (chowned to the huskies user below) so +# the in-process upgrade path (`POST /api/upgrade`) can atomically replace it +# without root: rename() needs write permission on the *directory*, not just +# the file. /usr/local/bin/huskies stays a symlink so PATH resolution and the +# entrypoint CMD are unchanged. +COPY --from=huskies-src /usr/local/bin/huskies /opt/huskies/bin/huskies COPY --from=huskies-src /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint.sh # Non-root user — Claude Code refuses --dangerously-skip-permissions as root. @@ -44,6 +49,8 @@ RUN groupadd -r huskies \ && mkdir -p /home/huskies/.ssh \ && chmod 700 /home/huskies/.ssh \ && chown -R huskies:huskies /home/huskies \ + && chown -R huskies:huskies /opt/huskies \ + && ln -s /opt/huskies/bin/huskies /usr/local/bin/huskies \ && mkdir -p /workspace \ && chown huskies:huskies /workspace \ && git config --global init.defaultBranch master \ diff --git a/server/src/upgrade.rs b/server/src/upgrade.rs index a78aacca..32aaa996 100644 --- a/server/src/upgrade.rs +++ b/server/src/upgrade.rs @@ -94,9 +94,22 @@ pub async fn run_cli_upgrade(source_url: &str, target: &Path) -> Result<(), Stri // ── Helpers ─────────────────────────────────────────────────────────────── -/// Resolve the path to replace: `current_exe()` if accessible, else -/// `/usr/local/bin/huskies`. +/// Resolve the path to replace with the new binary. +/// +/// Inside project containers the binary is installed at +/// `/opt/huskies/bin/huskies` (a huskies-owned directory, so the atomic +/// tmp-write + rename works without root) and `/usr/local/bin/huskies` is a +/// symlink to it. That path is preferred over `current_exe()` because +/// `current_exe()` can point at a stale location — e.g. +/// `/workspace/target/release/huskies` after a historical in-container +/// rebuild — which the entrypoint would never launch after a restart. +/// +/// Outside containers (no `/opt/huskies`), falls back to `current_exe()`. pub fn resolve_target_path() -> PathBuf { + let container_path = PathBuf::from("/opt/huskies/bin/huskies"); + if container_path.exists() { + return container_path; + } std::env::current_exe().unwrap_or_else(|_| PathBuf::from("/usr/local/bin/huskies")) }