Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ markers = [
"kimi_code: v1 e2e cases on the kimi-code harness",
"pi: v1 e2e cases on the pi harness",
"pool: v1 e2e cases on the pool harness",
"cline: v1 e2e cases on the Cline harness",
"codex: v1 e2e cases on the codex harness",
"unit: marks tests as unit tests",
"asyncio: marks tests as async tests",
Expand Down
6 changes: 3 additions & 3 deletions tests/v1/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@
uv run pytest tests/v1 -n auto -m modal # only modal (needs local setup)

Marks: runtimes `subprocess` / `docker` / `prime` / `modal`, placement `colocated`,
harnesses `null` / `bash` / `rlm` / `kimi_code` / `pi` / `pool` / `openclaw` / `codex` /
`claude_code` / `hermes_agent`.
harnesses `null` / `bash` / `rlm` / `kimi_code` / `pi` / `pool` / `openclaw` / `cline` /
`codex` / `claude_code` / `hermes_agent`.
A mark is applied per axis, so it selects every case touching that value on ANY axis; for one exact
combination use `-k` on the test id (e.g. `-k "harness-in-docker-with-tool-in-subprocess"`).
prime/modal provision real remote sandboxes (slow, infra-flaky, need setup), so they're local-only.
Expand Down Expand Up @@ -71,7 +71,7 @@ def tool_runtime(request) -> dict:


# Built-in harnesses are bundled in the `harnesses` package; the agent CLIs (`rlm` /
# `kimi-code` / `openclaw` / `codex` / `claude-code` / `hermes-agent`) install their
# `kimi-code` / `openclaw` / `cline` / `codex` / `claude-code` / `hermes-agent`) install their
# dependencies at rollout.
# `compact` (an example harness) and `terminus-2` (drives the host tmux) are excluded from e2e.
@pytest.fixture
Expand Down
49 changes: 49 additions & 0 deletions tests/v1/test_cline_harness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import json
Comment thread
arafatkatze marked this conversation as resolved.
Outdated
from types import SimpleNamespace
from typing import cast
from unittest.mock import AsyncMock

import pytest

from verifiers.v1.clients import ModelContext
from verifiers.v1.harnesses.cline.harness import ClineHarness, ClineHarnessConfig
from verifiers.v1.runtimes import ProgramResult
from verifiers.v1.task import TaskData
from verifiers.v1.trace import Trace


@pytest.mark.asyncio
async def test_prepare_acp_allows_user_to_open_conversation():
harness = ClineHarness(ClineHarnessConfig(id="cline"))
runtime = AsyncMock()
runtime.run.return_value = ProgramResult(exit_code=0, stdout="", stderr="")

config = await harness.prepare_acp(
cast(ModelContext, SimpleNamespace(model="test/model")),
cast(Trace, SimpleNamespace(id="trace-id")),
runtime,
"http://model.invalid/v1",
"interception-secret",
{"resume": "http://tool.invalid/mcp"},
TaskData(prompt=None, system_prompt="system"),
)

assert config.prompt is None
assert config.system_prompt == "system"
assert "--acp" in config.command
assert config.mcp_urls == {}
mcp_payload = next(
call.args[1]
for call in runtime.write.await_args_list
if call.args[0].endswith("cline_mcp_settings.json")
)
assert json.loads(mcp_payload) == {
"mcpServers": {
"resume": {
"transport": {
"type": "streamableHttp",
"url": "http://tool.invalid/mcp",
}
}
}
}
2 changes: 2 additions & 0 deletions tests/v1/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def pair(a: str, b: str, id: str, *extra_marks):
marks=[mark.kimi_code, mark.docker],
id="kimi-code-responses-harness-in-docker",
),
pair("cline", "docker", "cline-harness-in-docker"),
pair("codex", "docker", "codex-harness-in-docker"),
pair("claude-code", "docker", "claude-code-harness-in-docker"),
pair("hermes-agent", "docker", "hermes-agent-harness-in-docker"),
Expand All @@ -65,6 +66,7 @@ def pair(a: str, b: str, id: str, *extra_marks):
# retain MCP access after resuming. Cover every harness in the local container runtime,
# plus remote placements for the sandbox/tunnel and native-process boundaries.
ACP_RESUME_PLACEMENTS = [
pair("cline", "docker", "cline-acp-in-docker"),
pair("codex", "docker", "codex-acp-in-docker"),
pair("claude-code", "docker", "claude-code-acp-in-docker"),
pair("hermes-agent", "docker", "hermes-agent-acp-in-docker"),
Expand Down
3 changes: 3 additions & 0 deletions verifiers/v1/harnesses/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
ClaudeCodeHarness,
ClaudeCodeHarnessConfig,
)
from verifiers.v1.harnesses.cline import ClineHarness, ClineHarnessConfig
from verifiers.v1.harnesses.codex import CodexHarness, CodexHarnessConfig
from verifiers.v1.harnesses.hermes_agent import (
HermesAgentHarness,
Expand All @@ -31,6 +32,8 @@
"BrowserUseHarnessConfig",
"ClaudeCodeHarness",
"ClaudeCodeHarnessConfig",
"ClineHarness",
"ClineHarnessConfig",
"CodexHarness",
"CodexHarnessConfig",
"HermesAgentHarness",
Expand Down
69 changes: 69 additions & 0 deletions verifiers/v1/harnesses/cline/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# Cline harness

The `cline` harness runs the public Cline CLI through its native ACP server and
routes every model call through the rollout's interception server. Cline's tool
calls, results, usage, and final responses become ordinary verifiers trace nodes
and model-call records while one native session persists across resumed turns.

## Quickstart

Choose an isolated container or VM runtime. The harness installs its pinned Cline
release during trusted setup.

```bash
export OPENAI_API_KEY=...
uv run eval MY_TASKSET \
--env.agent.harness.id cline \
--env.agent.runtime.type docker \
--model openai/gpt-4.1-mini \
--client.base-url https://openrouter.ai/api/v1 \
--client.api-key-var OPENAI_API_KEY \
--no-push
```

`--no-push` keeps the run local. Inspect the resolved config and traces under
`outputs/<run>/configs/eval.json` and `outputs/<run>/traces.jsonl`.

## Harness configuration

```toml
[env.agent.harness]
id = "cline"
version = "3.0.57"
disabled_tools = [] # native Cline tool names to disable
```

Use the agent and sampling blocks for settings owned by verifiers:

```toml
[env.agent]
max_turns = 20
max_output_tokens = 32768

[env.agent.timeout]
setup = 600
rollout = 1800

[sampling]
temperature = 0.2
```

The interception server applies the configured model and sampling values to
Cline's requests before forwarding them upstream.

## Security posture and limitations

- Each rollout gets an isolated Cline data directory, deleted after scoring.
- Cline telemetry and update checks are disabled.
- Per-rollout MCP servers are loaded through Cline's native MCP settings file.
- Native Cline tools remain enabled unless they are listed in `disabled_tools`.
- The adapter keeps one native ACP session alive across env-driven user turns.
- Cline exposes synchronous `tool_call` and `tool_result` hooks, but this harness
does not advertise verifiers tool interception because the public hook control
surface does not preserve the full result-rewrite contract.
- Cline `3.0.57` has no max-step flag. Use verifiers' `max_turns`, token caps, and
rollout timeout; their stop conditions and errors are recorded on the trace.
- Eval-client traces preserve message/tool structure and provider-reported usage,
but exact token IDs and masks require a compatible training client/renderer.
- Compaction was not forced in the smoke validation. If Cline rewrites history,
inspect the resulting branches before using those samples for training.
3 changes: 3 additions & 0 deletions verifiers/v1/harnesses/cline/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from verifiers.v1.harnesses.cline.harness import ClineHarness, ClineHarnessConfig

__all__ = ["ClineHarness", "ClineHarnessConfig"]
174 changes: 174 additions & 0 deletions verifiers/v1/harnesses/cline/harness.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""Run the public Cline CLI through its native ACP server."""

import json
import logging
import shlex

from pydantic import Field

from verifiers.v1.acp import ACPConfig, ACPHarness
from verifiers.v1.clients import ModelContext
from verifiers.v1.configs.harness import HarnessConfig
from verifiers.v1.harnesses.node import NODE_BIN_DIR, ensure_node
from verifiers.v1.runtimes import Runtime
from verifiers.v1.task import TaskData
from verifiers.v1.trace import Trace

logger = logging.getLogger(__name__)

CLINE_DIR = "/var/tmp/vf-cline-{version}"
PACKAGES_DIR = f"{CLINE_DIR}/packages"
# Cline's package publishes its own platform resolver. npm 11 does not create a
# `.bin/cline` shim for this package in the minimal prefix install, so address the
# declared binary directly.
CLINE_BIN = f"{PACKAGES_DIR}/node_modules/cline/bin/cline"
CLINE_DATA_ROOT = "/tmp/vf-cline"

INSTALL = r"""
set -e
export PATH="/var/tmp/vf-node/bin:$PATH"
rm -f {ready}
npm install --prefix {packages} --no-audit --no-fund --omit=dev \
"cline@$VF_CLINE_VERSION" >/dev/null
touch {ready}
"""


class ClineHarnessConfig(HarnessConfig):
version: str = Field(default="3.0.57", pattern=r"^[A-Za-z0-9._+-]+$")
"""Public Cline CLI release to install, pinned for reproducibility."""


class ClineHarness(ACPHarness[ClineHarnessConfig]):
APPENDS_SYSTEM_PROMPT = True
SUPPORTS_MCP = True

async def setup(self, runtime: Runtime) -> None:
await ensure_node(runtime)
directory = CLINE_DIR.format(version=self.config.version)
packages = PACKAGES_DIR.format(version=self.config.version)
cline_bin = CLINE_BIN.format(version=self.config.version)
ready = f"{directory}/.ready"
script = INSTALL.replace("{packages}", packages).replace("{ready}", ready)
ensure = shlex.quote(f"[ -f {ready} ] && [ -x {cline_bin} ] || ({script})")
guarded = (
f"mkdir -p {directory} && "
f'"$(command -v flock || command -v lockf)" {directory}/install.lock '
f"sh -c {ensure}"
)
logger.info("cline: ensuring Cline CLI %s is installed", self.config.version)
result = await runtime.run(
["sh", "-c", guarded],
{
**self.config.resolved_env,
"VF_CLINE_VERSION": self.config.version,
},
)
if result.exit_code != 0:
detail = (result.stderr or result.stdout).strip()[-500:]
raise RuntimeError(f"Cline CLI install failed: {detail}")
await super().setup(runtime)

async def prepare_acp(
self,
ctx: ModelContext,
trace: Trace,
runtime: Runtime,
endpoint: str,
secret: str,
mcp_urls: dict[str, str],
data: TaskData,
) -> ACPConfig:
system_prompt, prompt = self.resolve_prompt(data)

data_dir = self.data_dir(trace)
settings_dir = f"{data_dir}/settings"
mcp_settings = f"{settings_dir}/cline_mcp_settings.json"
await runtime.write(
f"{settings_dir}/global-settings.json",
json.dumps({"disabledTools": self.config.disabled_tools or []}).encode(),
)
await runtime.write(
mcp_settings,
json.dumps(
{
"mcpServers": {
name: {
"transport": {
Comment thread
arafatkatze marked this conversation as resolved.
"type": "streamableHttp",
"url": url,
}
}
for name, url in mcp_urls.items()
}
}
).encode(),
)

env = {
**self.config.resolved_env,
"PATH": (
f"{NODE_BIN_DIR}:/usr/local/sbin:/usr/local/bin:"
"/usr/sbin:/usr/bin:/sbin:/bin"
),
"CLINE_DATA_DIR": data_dir,
"CLINE_MCP_SETTINGS_PATH": mcp_settings,
"CLINE_PROVIDER": "openai-compatible",
Comment thread
arafatkatze marked this conversation as resolved.
"CLINE_API_KEY": secret,
"CLINE_MODEL": ctx.model,
"CLINE_TELEMETRY_DISABLED": "1",
Comment thread
arafatkatze marked this conversation as resolved.
Outdated
"CLINE_NO_AUTO_UPDATE": "1",
"NO_UPDATE_NOTIFIER": "1",
}
# Execute npm's launcher through its shebang. Passing the `.bin` symlink
# to `node` directly bypasses the package launcher's intended resolution
# path for the platform-specific compiled binary.
cline = [CLINE_BIN.format(version=self.config.version)]
auth = await runtime.run(
[
*cline,
"auth",
"--provider",
"openai-compatible",
"--apikey",
secret,
"--modelid",
ctx.model,
"--baseurl",
endpoint,
"--data-dir",
data_dir,
],
env,
)
if auth.exit_code != 0:
detail = (auth.stderr or auth.stdout).strip()[-500:]
raise RuntimeError(f"Cline provider configuration failed: {detail}")

return ACPConfig(
env=env,
command=[
*cline,
"--acp",
"--auto-approve",
"true",
"--cwd",
".",
"--data-dir",
data_dir,
],
prompt=prompt,
# Cline reads task-scoped servers from CLINE_MCP_SETTINGS_PATH.
mcp_urls={},
system_prompt=system_prompt,
)

async def cleanup(self, trace: Trace, runtime: Runtime) -> None:
result = await runtime.run(["rm", "-rf", self.data_dir(trace)], {})
if result.exit_code != 0:
detail = (result.stderr or result.stdout).strip()[-500:]
raise RuntimeError(f"failed to clean up Cline data: {detail}")

@staticmethod
def data_dir(trace: Trace) -> str:
return f"{CLINE_DATA_ROOT}/{trace.id}"