diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index 1cdd8d8f..22b72af2 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -475,7 +475,21 @@ spec: # which self-heals after the pod has run on the other architecture. case "$(uname -m)" in x86_64) nodearch=x64 ;; aarch64) nodearch=arm64 ;; *) nodearch=unknown ;; esac codex_native="${tools}/lib/node_modules/@openai/codex-linux-${nodearch}" - if [ ! -f "${tools}/.cli-versions-0.154.0-2.1.226-${arch}" ] || [ ! -d "${codex_native}" ]; then + codex_version="$("${tools}/bin/codex" --version 2>/dev/null || true)" + claude_version="$("${tools}/bin/claude" --version 2>/dev/null || true)" + case "${codex_version}" in *0.154.0*) codex_ready=1 ;; *) codex_ready=0 ;; esac + case "${claude_version}" in *2.1.226*) claude_ready=1 ;; *) claude_ready=0 ;; esac + if [ ! -f "${tools}/.cli-versions-0.154.0-2.1.226-${arch}" ] || [ ! -d "${codex_native}" ] \ + || [ "${codex_ready}" != "1" ] || [ "${claude_ready}" != "1" ]; then + # Interrupted npm global installs leave only these private + # staging directories; never follow or remove other entries. + for scope in "${tools}/lib/node_modules/@anthropic-ai" "${tools}/lib/node_modules/@openai"; do + [ -d "${scope}" ] && [ ! -L "${scope}" ] || continue + case "${scope}" in + */@anthropic-ai) find "${scope}" -mindepth 1 -maxdepth 1 -type d -name '.claude-code-*' -exec rm -rf -- {} + ;; + */@openai) find "${scope}" -mindepth 1 -maxdepth 1 -type d -name '.codex-*' -exec rm -rf -- {} + ;; + esac + done npm install --global --omit=dev --no-audit --no-fund --prefix "${tools}" \ @openai/codex@0.154.0 \ @anthropic-ai/claude-code@2.1.226 diff --git a/testing/tests/test_hermes_agent_runtime_patches.py b/testing/tests/test_hermes_agent_runtime_patches.py index b3b07013..0316f5c0 100644 --- a/testing/tests/test_hermes_agent_runtime_patches.py +++ b/testing/tests/test_hermes_agent_runtime_patches.py @@ -2,6 +2,9 @@ from __future__ import annotations +import os +import subprocess + from testing.tests.test_hermes_cli_support import ( HERMES, Path, @@ -61,6 +64,13 @@ def test_owner_agent_installs_the_pinned_operator_toolchain(): ) assert "/bin/sh /opt/coordinator/install_agent_tools.sh" in installer["command"][2] assert any(mount["name"] == "coordinator" for mount in installer["volumeMounts"]) + install = installer["command"][2] + assert 'codex_version="$("${tools}/bin/codex" --version' in install + assert 'claude_version="$("${tools}/bin/claude" --version' in install + assert "@anthropic-ai" in install and "-name '.claude-code-*'" in install + assert "@openai" in install and "-name '.codex-*'" in install + assert "-mindepth 1 -maxdepth 1 -type d" in install + assert "[ ! -L \"${scope}\" ]" in install init_config = next( item for item in deployment["spec"]["template"]["spec"]["initContainers"] @@ -72,6 +82,60 @@ def test_owner_agent_installs_the_pinned_operator_toolchain(): assert 'chmod 0644 "${profile_file}"' in init_command +def test_npm_reinstall_removes_only_known_interrupted_staging_directories(tmp_path): + """A stale npm rename target is cleared before the pinned reinstall only.""" + installer = next( + item + for item in _agent_deployment()["spec"]["template"]["spec"]["initContainers"] + if item["name"] == "install-agent-tools" + ) + tools = tmp_path / "tools" + bin_dir = tools / "bin" + bin_dir.mkdir(parents=True) + for name, output in { + "ttyd": "ttyd 1.7.7\n", + "kubectl": '{"gitVersion":"v1.33.3"}\n', + "codex": "codex 0.154.0\n", + "claude": "2.1.226\n", + }.items(): + path = bin_dir / name + path.write_text(f"#!/bin/sh\nprintf '%s' '{output}'\n", encoding="utf-8") + path.chmod(0o755) + for scope, stale in (("@anthropic-ai", ".claude-code-aborted"), ("@openai", ".codex-aborted")): + path = tools / "lib/node_modules" / scope / stale + path.mkdir(parents=True) + (path / "partial").write_text("incomplete", encoding="utf-8") + preserved = tools / "lib/node_modules/@anthropic-ai/keep-me" + preserved.mkdir(parents=True) + (preserved / "data").write_text("preserve", encoding="utf-8") + fake_npm = tmp_path / "fake-bin/npm" + fake_npm.parent.mkdir() + fake_npm.write_text( + "#!/bin/sh\n" + "test ! -e \"$HERMES_TEST_TOOLS/lib/node_modules/@anthropic-ai/.claude-code-aborted\"\n" + "test ! -e \"$HERMES_TEST_TOOLS/lib/node_modules/@openai/.codex-aborted\"\n", + encoding="utf-8", + ) + fake_npm.chmod(0o755) + command = installer["command"][2] + command = command.replace("tools=/opt/data/tools", f"tools={tools}") + command = command.replace("/bin/sh /opt/coordinator/install_agent_tools.sh", ":") + command = command.replace('HOME=/opt/data/home git config --global user.name "Hermes Agent"', ":") + command = command.replace('HOME=/opt/data/home git config --global user.email "hermes@bstein.dev"', ":") + result = subprocess.run( + ["sh", "-c", command], + text=True, + capture_output=True, + env={ + **os.environ, + "PATH": f"{fake_npm.parent}:{os.environ['PATH']}", + "HERMES_TEST_TOOLS": str(tools), + }, + ) + assert result.returncode == 0, result.stderr + assert (preserved / "data").read_text(encoding="utf-8") == "preserve" + + def test_owner_agent_uses_the_canonical_hostname(): paths = [ HERMES / "agent-configmap.yaml",