Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions responses_api_agents/anyswe_agent/tests/test_runtime_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
4 changes: 3 additions & 1 deletion responses_api_agents/cline_agent/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. 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`.

Expand Down
4 changes: 4 additions & 0 deletions responses_api_agents/cline_agent/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

app.py:501

Nice fix here. One call is still not covered: cline_agent_deps.sh:33 runs "$DEPS_DIR/bin/cline" --version right after the pinned install, with the server's env and no opt-out. Under this PR's premise that arms npm update -g cline against the same $DEPS_DIR the sandbox later mounts, so the AnySWE recipe can still end up unpinned (the 3.0.55 → 3.0.71 drift), just once per recipe instead of per rollout. Could you prefix it?

Suggested change
"CLINE_NO_AUTO_UPDATE": "1",
CLINE_NO_AUTO_UPDATE=1 "$DEPS_DIR/bin/cline" --version

(for cline_agent_deps.sh:33; GitHub can't attach the suggestion there since the file isn't in this diff.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in c4d6d37: cline_agent_deps.sh now runs CLINE_NO_AUTO_UPDATE=1 "$DEPS_DIR/bin/cline" --version, with a test that every bin/cline call in the script carries it. A/B of that script section on a Linux host with a scratch DEPS_DIR: the old line started npm update -g cline and left DEPS_DIR at 3.0.73; the new one ran no update and stayed at 3.0.55. Details in the PR body.

}
# 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.
Expand Down
5 changes: 4 additions & 1 deletion responses_api_agents/cline_agent/setup_cline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions responses_api_agents/cline_agent/tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -455,6 +456,18 @@ 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_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)
Expand Down Expand Up @@ -518,6 +531,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()
Expand Down
13 changes: 13 additions & 0 deletions responses_api_agents/cline_agent/tests/test_setup_cline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"),
Expand Down
Loading