From c85010de8a151e4b5735b16e28b03759c9b8edc9 Mon Sep 17 00:00:00 2001 From: qiaochuz Date: Thu, 15 Oct 2026 14:38:46 -0700 Subject: [PATCH 1/2] fix(agents): disable Cline auto-update so the pinned CLI holds Every `cline` call, `--version` included, records a pending self-update when npm has a newer release and spawns a detached `npm update -g cline` on exit. The post-install version probe in ensure_cline therefore replaced the pinned 3.0.55 install mid-collection and briefly removed the binary, so a concurrent task spawn failed with FileNotFoundError (HTTP 500, collection aborted). Set CLINE_NO_AUTO_UPDATE=1 (the only value Cline honours) on the version probe and in the env for `cline auth` and the task, without touching the server's own os.environ. Closes #4502 Co-Authored-By: Claude Opus 5.5 Signed-off-by: qiaochuz --- responses_api_agents/cline_agent/README.md | 4 +++- responses_api_agents/cline_agent/app.py | 4 ++++ .../cline_agent/setup_cline.py | 5 ++++- .../cline_agent/tests/test_app.py | 20 +++++++++++++++++++ .../cline_agent/tests/test_setup_cline.py | 13 ++++++++++++ 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/responses_api_agents/cline_agent/README.md b/responses_api_agents/cline_agent/README.md index 10bc5effdb6..49d69971764 100644 --- a/responses_api_agents/cline_agent/README.md +++ b/responses_api_agents/cline_agent/README.md @@ -116,7 +116,9 @@ model: claude-sonnet-4-5 commands Cline may run (e.g. `{allow: ["python3 *"], deny: ["sudo *"]}`); empty means unrestricted - `cline_version`: `cline` npm version installed on a clean machine (shipped pinned to `3.0.55`; the event parser was validated against it, so treat a bump as a deliberate change: raise it, - re-run the tests and a live eval, then commit). `null` installs `@latest` + re-run the tests and a live eval, then commit). `null` installs `@latest`. Every `cline` call Gym + makes sets `CLINE_NO_AUTO_UPDATE=1`, so Cline's self-update cannot replace the pinned install + mid-run See `configs/cline_agent.yaml`. diff --git a/responses_api_agents/cline_agent/app.py b/responses_api_agents/cline_agent/app.py index 47b0cda8447..6134b9da455 100644 --- a/responses_api_agents/cline_agent/app.py +++ b/responses_api_agents/cline_agent/app.py @@ -495,6 +495,10 @@ def _env(self, data_dir: Path, model_base_url: str = "") -> dict[str, str]: # Local session backend: a shared hub daemon would hand concurrent rollouts the same # process and state, which is exactly what the per-run data dir is avoiding. "CLINE_SESSION_BACKEND_MODE": "local", + # Cline otherwise ends every call by spawning a detached `npm update -g cline`, which + # replaces the pinned cline_version mid-collection and briefly removes the binary from + # under concurrent spawns. Only the exact value "1" disables it. + "CLINE_NO_AUTO_UPDATE": "1", } # A Gym model server wins over openai_base_url, so a config carrying both cannot point the # subprocess at the provider the model server is meant to replace. diff --git a/responses_api_agents/cline_agent/setup_cline.py b/responses_api_agents/cline_agent/setup_cline.py index cb1b6e524d1..5d93441cd59 100644 --- a/responses_api_agents/cline_agent/setup_cline.py +++ b/responses_api_agents/cline_agent/setup_cline.py @@ -38,7 +38,10 @@ def _cline_version(cline_bin: str) -> str | None: - result = subprocess.run([cline_bin, "--version"], capture_output=True, text=True) + # Without the opt-out, even `--version` ends by spawning a detached `npm update -g cline` that + # replaces the pinned install; see ClineAgent._env. + env = {**os.environ, "CLINE_NO_AUTO_UPDATE": "1"} + result = subprocess.run([cline_bin, "--version"], capture_output=True, text=True, env=env) if result.returncode != 0: return None match = re.search(r"\b\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?\b", result.stdout + result.stderr) diff --git a/responses_api_agents/cline_agent/tests/test_app.py b/responses_api_agents/cline_agent/tests/test_app.py index bc1f8c50447..52825dcd39e 100644 --- a/responses_api_agents/cline_agent/tests/test_app.py +++ b/responses_api_agents/cline_agent/tests/test_app.py @@ -15,6 +15,7 @@ import asyncio import json +import os from functools import partial from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch @@ -455,6 +456,13 @@ def test_ambient_cline_env_is_overridden(self, tmp_path, monkeypatch) -> None: assert env["CLINE_DATA_DIR"] == str(tmp_path) assert env["CLINE_PROVIDER_SETTINGS_PATH"] == str(tmp_path / "settings" / "providers.json") + def test_auto_update_disabled(self, tmp_path, monkeypatch) -> None: + # Cline's self-update would replace the pinned cline_version mid-collection; an ambient + # value must not re-enable it, and the server's own environment is left untouched. + monkeypatch.setenv("CLINE_NO_AUTO_UPDATE", "0") + assert _make_agent()._env(tmp_path)["CLINE_NO_AUTO_UPDATE"] == "1" + assert os.environ["CLINE_NO_AUTO_UPDATE"] == "0" + def test_command_permissions_serialized(self, tmp_path) -> None: agent = _make_agent(command_permissions={"allow": ["python3 *"], "deny": ["sudo *"]}) env = agent._env(tmp_path) @@ -518,6 +526,18 @@ def test_run_cline_authenticates_then_runs_with_same_url(self, tmp_path) -> None assert "--json" in run_argv assert spawn.call_args_list[1].kwargs["env"]["OPENAI_BASE_URL"] == expected + def test_run_cline_subprocesses_disable_auto_update(self, tmp_path) -> None: + # Both `cline auth` and the task arm the updater on exit, so both must carry the opt-out. + agent = _make_model_server_agent(model="m", repo_dir=str(tmp_path), workspace_root=str(tmp_path / "ws")) + proc = MagicMock() + proc.returncode = 0 + proc.communicate = AsyncMock(return_value=(b"", b"")) + with patch("responses_api_agents.cline_agent.app.asyncio.create_subprocess_exec") as spawn: + spawn.return_value = proc + asyncio.run(agent._run_cline("fix it", None, "task0-rollout1")) + assert spawn.call_count == 2 + assert [call.kwargs["env"]["CLINE_NO_AUTO_UPDATE"] for call in spawn.call_args_list] == ["1", "1"] + def test_run_cline_skips_auth_without_model_server(self, tmp_path) -> None: agent = _make_agent(repo_dir=str(tmp_path), workspace_root=str(tmp_path / "ws")) proc = MagicMock() diff --git a/responses_api_agents/cline_agent/tests/test_setup_cline.py b/responses_api_agents/cline_agent/tests/test_setup_cline.py index a5a80560603..945a348ef07 100644 --- a/responses_api_agents/cline_agent/tests/test_setup_cline.py +++ b/responses_api_agents/cline_agent/tests/test_setup_cline.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -28,6 +29,18 @@ def test_cline_version_parses_cli_output() -> None: assert _cline_version("/usr/bin/cline") == "3.0.55" +def test_version_probe_disables_auto_update(monkeypatch: pytest.MonkeyPatch) -> None: + # Every `cline` call, `--version` included, arms Cline's self-update: on exit it spawns a + # detached `npm update -g cline` that replaces the pinned install and briefly removes the + # binary under concurrent task spawns. Only the exact value "1" turns it off. + monkeypatch.setenv("CLINE_NO_AUTO_UPDATE", "0") + result = SimpleNamespace(returncode=0, stdout="3.0.55\n", stderr="") + with patch("responses_api_agents.cline_agent.setup_cline.subprocess.run", return_value=result) as run: + assert _cline_version("/usr/bin/cline") == "3.0.55" + assert run.call_args.kwargs["env"]["CLINE_NO_AUTO_UPDATE"] == "1" + assert os.environ["CLINE_NO_AUTO_UPDATE"] == "0" + + def test_exact_existing_version_is_reused() -> None: with ( patch("responses_api_agents.cline_agent.setup_cline.shutil.which", return_value="/usr/bin/cline"), From c4d6d376f37c61c308cec5a62df20659e4440f20 Mon Sep 17 00:00:00 2001 From: qiaochuz Date: Thu, 22 Oct 2026 10:57:07 -0700 Subject: [PATCH 2/2] fix(agents): disable Cline auto-update in the AnySWE deps script Review follow-up. cline_agent_deps.sh validated the pinned install with a bare `cline --version`, which arms Cline's self-update against $DEPS_DIR, the bundle the AnySWE sandbox mounts. Prefix it with CLINE_NO_AUTO_UPDATE=1. Also say in the README that the opt-out is a default an explicit config.env can override, and pin that precedence with a test. Co-Authored-By: Claude Opus 5.5 Signed-off-by: qiaochuz --- .../anyswe_agent/setup_scripts/cline_agent_deps.sh | 4 +++- .../anyswe_agent/tests/test_runtime_bundle.py | 10 ++++++++++ responses_api_agents/cline_agent/README.md | 6 +++--- responses_api_agents/cline_agent/tests/test_app.py | 5 +++++ 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/responses_api_agents/anyswe_agent/setup_scripts/cline_agent_deps.sh b/responses_api_agents/anyswe_agent/setup_scripts/cline_agent_deps.sh index 885d2491e51..d56a64bbc56 100755 --- a/responses_api_agents/anyswe_agent/setup_scripts/cline_agent_deps.sh +++ b/responses_api_agents/anyswe_agent/setup_scripts/cline_agent_deps.sh @@ -30,4 +30,6 @@ fi export PATH="$DEPS_DIR/bin:$PATH" npm install -g --prefix "$DEPS_DIR" "cline@${CLINE_VERSION}" -"$DEPS_DIR/bin/cline" --version +# Without the opt-out, even `--version` ends by spawning a detached `npm update -g cline` against +# $DEPS_DIR, which would unpin the bundle the sandbox mounts. +CLINE_NO_AUTO_UPDATE=1 "$DEPS_DIR/bin/cline" --version diff --git a/responses_api_agents/anyswe_agent/tests/test_runtime_bundle.py b/responses_api_agents/anyswe_agent/tests/test_runtime_bundle.py index 81dd1faff23..b1370b57a90 100644 --- a/responses_api_agents/anyswe_agent/tests/test_runtime_bundle.py +++ b/responses_api_agents/anyswe_agent/tests/test_runtime_bundle.py @@ -67,3 +67,13 @@ def test_setup_script_reinstalls_nemo_gym_from_the_checkout(self) -> None: helper = (Path(__file__).parent.parent / "setup_scripts" / "_portable_python.sh").read_text() assert 'install_python_packages --no-deps --force-reinstall "$NEMO_GYM_ROOT"' in helper assert 'install_python_packages --no-deps --reinstall "$NEMO_GYM_ROOT"' in helper + + def test_cline_setup_script_disables_auto_update(self) -> None: + # Any `cline` call, `--version` included, otherwise spawns a detached `npm update -g cline` + # against $DEPS_DIR on exit, unpinning the bundle the sandbox mounts. + script = (Path(__file__).parent.parent / "setup_scripts" / "cline_agent_deps.sh").read_text() + calls = [ + line.strip() for line in script.splitlines() if "bin/cline" in line and not line.lstrip().startswith("#") + ] + assert calls + assert all(line.startswith("CLINE_NO_AUTO_UPDATE=1 ") for line in calls) diff --git a/responses_api_agents/cline_agent/README.md b/responses_api_agents/cline_agent/README.md index 49d69971764..f74396afad0 100644 --- a/responses_api_agents/cline_agent/README.md +++ b/responses_api_agents/cline_agent/README.md @@ -116,9 +116,9 @@ model: claude-sonnet-4-5 commands Cline may run (e.g. `{allow: ["python3 *"], deny: ["sudo *"]}`); empty means unrestricted - `cline_version`: `cline` npm version installed on a clean machine (shipped pinned to `3.0.55`; the event parser was validated against it, so treat a bump as a deliberate change: raise it, - re-run the tests and a live eval, then commit). `null` installs `@latest`. Every `cline` call Gym - makes sets `CLINE_NO_AUTO_UPDATE=1`, so Cline's self-update cannot replace the pinned install - mid-run + re-run the tests and a live eval, then commit). `null` installs `@latest`. By default every `cline` + call Gym makes sets `CLINE_NO_AUTO_UPDATE=1`, so Cline's self-update cannot replace the pinned + install mid-run; an explicit `env: {CLINE_NO_AUTO_UPDATE: "0"}` re-enables it See `configs/cline_agent.yaml`. diff --git a/responses_api_agents/cline_agent/tests/test_app.py b/responses_api_agents/cline_agent/tests/test_app.py index 52825dcd39e..4351cf0fa06 100644 --- a/responses_api_agents/cline_agent/tests/test_app.py +++ b/responses_api_agents/cline_agent/tests/test_app.py @@ -463,6 +463,11 @@ def test_auto_update_disabled(self, tmp_path, monkeypatch) -> None: assert _make_agent()._env(tmp_path)["CLINE_NO_AUTO_UPDATE"] == "1" assert os.environ["CLINE_NO_AUTO_UPDATE"] == "0" + def test_config_env_can_reenable_auto_update(self, tmp_path) -> None: + # config.env is applied last, like for every other key, so an explicit opt-in still wins. + env = _make_agent(env={"CLINE_NO_AUTO_UPDATE": "0"})._env(tmp_path) + assert env["CLINE_NO_AUTO_UPDATE"] == "0" + def test_command_permissions_serialized(self, tmp_path) -> None: agent = _make_agent(command_permissions={"allow": ["python3 *"], "deny": ["sudo *"]}) env = agent._env(tmp_path)