Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
6d3ab15
Print harness output as UTF-8 on a legacy Windows console
TON14 Aug 20, 2026
b201c86
Run agent CLIs without a shell so the harness works on Windows
TON14 Aug 20, 2026
f5a288c
Give the 0.1.4 hardening layer Windows branches
TON14 Aug 20, 2026
f01e94b
Let the test suite run on Windows
TON14 Aug 20, 2026
dede451
Document Windows as a tested platform
TON14 Aug 20, 2026
cbf36a8
Wait out transient provider failures instead of aborting the run
TON14 Aug 20, 2026
09dccc6
Accept the venv launcher pid in supervised owner checks on Windows
TON14 Aug 20, 2026
25cdeb4
Keep the 0.1.7 test additions runnable on Windows and the argv adapters
TON14 Aug 20, 2026
732ecff
Keep PWD in step with the directory a child is started in
TON14 Aug 20, 2026
e3ac679
Ask the platform, not the path, about drive letters
TON14 Aug 20, 2026
ed6d772
Deliver the DeepSeek Harness task via a patch layer on Windows
TON14 Aug 20, 2026
7a67eb1
Exercise both dsh task deliveries on every platform
TON14 Aug 20, 2026
eec3250
Run the suite on both platforms in CI
TON14 Aug 20, 2026
c5f75ea
Probe process liveness without killing the process on Windows
TON14 Aug 20, 2026
6a0bde7
Keep the platform probes and the cross-platform dsh test honest under CI
TON14 Aug 20, 2026
3e1bcd9
Revert "Wait out transient provider failures instead of aborting the …
TON14 Aug 20, 2026
a547c14
Let runs opt Claude Code agents out of the operator's own toolbox
TON14 Aug 23, 2026
b545a6a
Keep the CLI's built-in skills available under isolation
TON14 Aug 23, 2026
f19ea5a
Point the empty-name refusal at the right spelling of "allow nothing"
TON14 Aug 23, 2026
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
43 changes: 43 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: 🧪 Tests

on:
push:
branches:
- main
pull_request:
workflow_dispatch:

permissions:
contents: read

concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true

jobs:
pytest:
name: 🐍 ${{ matrix.os }} · py${{ matrix.python }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# Both supported platforms, at both ends of requires-python: the
# oldest interpreter the project promises and the newest it is
# developed against. The suite needs no Node toolchain -- the Web
# bundle is a packaging artifact, not a test dependency.
os: [ubuntu-latest, windows-latest]
python: ["3.10", "3.14"]
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}

- name: 📦 Install with test dependencies
run: python -m pip install -e ".[test]"

- name: 🧪 Run the suite
# The Windows symlink fixtures skip themselves when the runner lacks
# SeCreateSymbolicLinkPrivilege; those skips are expected and green.
run: python -m pytest -q
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ Steps 1–2 are once per machine; step 3 is once per project. Then run tasks fro
| One agent runtime on `PATH`: [`codex`](https://github.com/openai/codex#installing-and-running-codex-cli), [`claude`](https://docs.anthropic.com/en/docs/claude-code/getting-started), [`opencode`](https://github.com/anomalyco/opencode), or [`dsh`](https://github.com/deepseek-ai/deepseek-harness) | Actually executing the work. Install more than one if you want to mix backends across roles. |
| [Node.js](https://nodejs.org) 20 or later | The npm-distributed computer-use plugins. DeepSeek Harness itself currently requires Node.js `^22.19.0` or `>=24.0.0`. |

> **Platform status:** Currently tested on macOS. Windows support is included but has not yet been thoroughly tested.
> **Platform status:** Tested on macOS and Windows. Agent CLIs are launched as plain subprocesses — no shell is involved — so command construction behaves identically on every platform. On Windows the harness also escapes the 260-character `MAX_PATH` limit automatically, which run directories reach easily on a deep project path.

Run `lh-harness doctor` at any point to check all of the above; see [Verify the environment](#verify-the-environment).

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ LongHorizon-Harness 不只展示了几个精心挑选的成功案例。
| `PATH` 上有一个 Agent 运行时:[`codex`](https://github.com/openai/codex#installing-and-running-codex-cli)、[`claude`](https://docs.anthropic.com/en/docs/claude-code/getting-started)、[`opencode`](https://github.com/anomalyco/opencode) 或 [`dsh`](https://github.com/deepseek-ai/deepseek-harness) | 真正执行任务。想按角色混用多个后端就安装多个。 |
| [Node.js](https://nodejs.org) 20 或更高版本 | npm 分发的 computer-use 插件需要;DeepSeek Harness 本身目前要求 Node.js `^22.19.0` 或 `>=24.0.0`。 |

> **平台状态:** 目前只在 macOS 上完成了测试;Windows 已支持,但尚未经过详细测试。
> **平台状态:** 已在 macOS 和 Windows 上测试。Agent CLI 以普通子进程方式启动,不经过 shell,因此命令构造在所有平台上行为一致。在 Windows 上还会自动绕开 260 字符的 `MAX_PATH` 限制——项目路径较深时,运行目录很容易超过这个长度。

以上都可以用 `lh-harness doctor` 一次性检查,见[检查运行环境](#检查运行环境)。

Expand Down
83 changes: 57 additions & 26 deletions src/lh_harness/adapters/claude_code.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
from __future__ import annotations

import os
import shlex
from pathlib import Path

from ..agent_logs import visible_output as extract_claude_visible_output
from ..agent_registry import normalise_reasoning_effort
from .claude_isolation import build_skills_plugin, resolve_plugin_dirs
from .claude_permissions import (
ClaudeRole,
is_auditor_role,
Expand Down Expand Up @@ -41,25 +41,31 @@ def __init__(
hidden_paths: tuple[str, ...] = (),
guard_exclude_paths: tuple[str, ...] = (),
reasoning_effort: str | None = None,
isolation: bool = False,
allowed_plugins: tuple[str, ...] = (),
allowed_skills: tuple[str, ...] = (),
) -> None:
policy = policy_for_role(role)
# Naming what is allowed only makes sense against a clean slate, so a
# non-empty allow-list implies isolation rather than silently doing
# nothing without the boolean.
isolation = bool(isolation or allowed_plugins or allowed_skills)
effort = normalise_reasoning_effort(reasoning_effort)
env_parts: list[str] = []
env_overrides: dict[str, str] = {}
if api_key:
quoted_key = shlex.quote(api_key)
env_parts.append(f"ANTHROPIC_API_KEY={quoted_key}")
env_parts.append(f"ANTHROPIC_AUTH_TOKEN={quoted_key}")
env_overrides["ANTHROPIC_API_KEY"] = api_key
env_overrides["ANTHROPIC_AUTH_TOKEN"] = api_key
if base_url:
raw_url = base_url.rstrip("/")
if raw_url.endswith("/v1"):
raw_url = raw_url[:-3]
env_parts.append(f"ANTHROPIC_BASE_URL={shlex.quote(raw_url)}")
env_parts.extend(
[
"CLAUDE_CODE_DISABLE_AUTO_MEMORY=1",
"CLAUDE_CODE_SKIP_PROMPT_HISTORY=1",
f"LH_HARNESS_CLAUDE_ROLE={shlex.quote(role)}",
]
env_overrides["ANTHROPIC_BASE_URL"] = raw_url
env_overrides.update(
{
"CLAUDE_CODE_DISABLE_AUTO_MEMORY": "1",
"CLAUDE_CODE_SKIP_PROMPT_HISTORY": "1",
"LH_HARNESS_CLAUDE_ROLE": role,
}
)

# MCP support remains opt-in. --strict-mcp-config keeps unrelated
Expand All @@ -82,45 +88,70 @@ def __init__(
)

if is_auditor_role(role):
env_parts.extend(
[
"GIT_OPTIONAL_LOCKS=0",
"GIT_PAGER=cat",
"PAGER=cat",
]
env_overrides.update(
{
"GIT_OPTIONAL_LOCKS": "0",
"GIT_PAGER": "cat",
"PAGER": "cat",
}
)

env_prefix = (" ".join(env_parts) + " ") if env_parts else ""
command_parts = [
argv = [
"claude",
"--print",
"--output-format",
"stream-json",
"--verbose",
"--dangerously-skip-permissions",
]
if isolation:
# Project-only setting sources drop everything the *operator's
# account* accumulated -- user plugins, skills, hooks, user-level
# CLAUDE.md -- while keeping what the workspace repo itself
# declares, which the task may rely on. Not `--bare`: bare mode
# also skips the account's OAuth credentials, so every run without
# an explicit API key dies with "Not logged in".
argv.extend(["--setting-sources", "project"])
plugin_dirs = resolve_plugin_dirs(tuple(allowed_plugins))
skills_plugin = build_skills_plugin(
tuple(allowed_skills),
Path(prompt_dir).parent / "claude-allowed-skills",
)
if skills_plugin:
plugin_dirs.append(skills_plugin)
for plugin_dir in plugin_dirs:
argv.extend(["--plugin-dir", plugin_dir])
# Deliberately no --disable-slash-commands: isolation removes the
# *operator account's* layer, and project-only setting sources
# already keep user skills from resolving. The CLI's own built-in
# skills ship identically for everyone and stay available, exactly
# like its built-in tools.
deny_tools = [*policy.disallowed_tools, *path_deny_rules(hidden_paths)]
if deny_tools:
command_parts.append("--disallowedTools")
command_parts.extend(shlex.quote(tool) for tool in deny_tools)
argv.append("--disallowedTools")
argv.extend(deny_tools)
self.computer_mcp_configured = bool(policy.load_computer_mcp and mcp_config)
if self.computer_mcp_configured:
command_parts.extend(["--mcp-config", shlex.quote(mcp_config)])
command_parts.extend(["--model", shlex.quote(model)])
argv.extend(["--mcp-config", mcp_config])
argv.extend(["--model", model])
# Claude Code warns and continues at its default when the value is not
# one it knows, so an unusable effort will not fail the run here.
if effort:
command_parts.extend(["--effort", shlex.quote(effort)])
argv.extend(["--effort", effort])

self.role = role
self.policy = policy
self.reasoning_effort = effort
self.isolation = isolation
self.allowed_plugins = tuple(allowed_plugins)
self.allowed_skills = tuple(allowed_skills)
# Snapshot-only exclusions: unlike hidden_paths these are not denied
# to the agent — the guard just refrains from walking directories that
# legitimately churn (build outputs) during an audit window.
self.guard_exclude_paths = tuple(guard_exclude_paths)
super().__init__(
command_template=f"{env_prefix}{' '.join(command_parts)} < {{prompt_path}}",
argv=argv,
env=env_overrides,
prompt_dir=prompt_dir,
workspace_path=workspace_path,
visible_output_parser=extract_claude_visible_output,
Expand Down
147 changes: 147 additions & 0 deletions src/lh_harness/adapters/claude_isolation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
"""Keep the operator's Claude Code customisations out of harness agents.

A harness agent is a subprocess of the operator's account, so by default the
``claude`` CLI loads everything that account has accumulated: plugins, skills,
hooks, user-level CLAUDE.md. None of that was chosen for the run. It spends
context tokens in every episode, a skill description can trigger on task text
it was never meant for, and a hook can rewrite tool calls the harness believes
it controls. The harness already isolates the DeepSeek CLI behind its own
``DSH_HOME`` and scopes MCP with ``--strict-mcp-config``; this module gives
Claude Code the same property.

Isolation is opt-in and selective, because sometimes part of the toolbox is
wanted: ``--bare`` starts the CLI without the account's context, and the
allow-lists re-admit exactly the named plugins and skills. Plugins resolve
through the CLI's own install manifest rather than by guessing cache paths.
A skill has no standalone CLI switch, but a plugin may carry skills, so the
allowed skills ride in a synthesised single-purpose plugin directory.
"""

from __future__ import annotations

import json
import re
import shutil
from pathlib import Path

_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")

SKILLS_PLUGIN_NAME = "lh-harness-allowed-skills"


def _checked_name(name: str, *, kind: str) -> str:
cleaned = str(name or "").strip()
if not _NAME_RE.match(cleaned):
raise ValueError(
f"invalid {kind} name {name!r}: letters, digits, '.', '_' and '-' only"
)
return cleaned


def default_claude_home() -> Path:
return Path.home() / ".claude"


def resolve_plugin_dirs(
names: list[str] | tuple[str, ...],
*,
claude_home: Path | None = None,
) -> list[str]:
"""Map installed-plugin names to their on-disk directories.

Reads the CLI's own ``installed_plugins.json`` (the authority on what is
installed and where) instead of walking the cache layout, which encodes
marketplace and version segments that are not a stable interface. A name
matches either bare (``playwright``) or fully qualified
(``playwright@claude-plugins-official``); a bare name that matches more
than one marketplace is an error rather than a guess.
"""

if not names:
return []
home = claude_home or default_claude_home()
manifest_path = home / "plugins" / "installed_plugins.json"
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except FileNotFoundError:
raise ValueError(
f"no Claude Code plugins are installed (missing {manifest_path})"
) from None
except (OSError, json.JSONDecodeError) as exc:
raise ValueError(f"could not read {manifest_path}: {exc}") from exc

entries: dict[str, list[dict]] = manifest.get("plugins") or {}
resolved: list[str] = []
for raw in names:
wanted = _checked_name(raw, kind="plugin") if "@" not in str(raw) else str(raw).strip()
matches = [
installs
for qualified, installs in entries.items()
if qualified == wanted or qualified.split("@", 1)[0] == wanted
]
if not matches:
installed = ", ".join(sorted(entries)) or "none"
raise ValueError(
f"Claude Code plugin {wanted!r} is not installed (installed: {installed})"
)
if len(matches) > 1:
raise ValueError(
f"Claude Code plugin name {wanted!r} is ambiguous across marketplaces; "
"qualify it as name@marketplace"
)
installs = matches[0]
install_path = str((installs or [{}])[0].get("installPath") or "")
if not install_path or not (Path(install_path) / ".claude-plugin" / "plugin.json").is_file():
raise ValueError(
f"Claude Code plugin {wanted!r} has no usable install at {install_path!r}"
)
resolved.append(install_path)
return resolved


def build_skills_plugin(
names: list[str] | tuple[str, ...],
target_dir: Path,
*,
claude_home: Path | None = None,
) -> str | None:
"""Materialise the allowed user skills as one synthesised plugin.

Copies (never symlinks: creating links needs a privilege most Windows
accounts lack) each ``~/.claude/skills/<name>`` into a plugin-shaped
directory under the run's own tree. The copy also freezes the skill for
the run: an edit to the live skill mid-run cannot change agent behaviour
between episodes.
"""

if not names:
return None
home = claude_home or default_claude_home()
skills_root = home / "skills"
target_dir = Path(target_dir)
if target_dir.exists():
shutil.rmtree(target_dir)
(target_dir / ".claude-plugin").mkdir(parents=True)
(target_dir / ".claude-plugin" / "plugin.json").write_text(
json.dumps(
{
"name": SKILLS_PLUGIN_NAME,
"version": "0.0.0",
"description": "User skills explicitly allowed for this LongHorizon-Harness run.",
},
indent=2,
)
+ "\n",
encoding="utf-8",
)
for raw in names:
name = _checked_name(raw, kind="skill")
source = skills_root / name
if not (source / "SKILL.md").is_file():
available = ", ".join(sorted(p.name for p in skills_root.glob("*/") )) or "none"
raise ValueError(
f"Claude Code skill {name!r} not found under {skills_root} "
f"(available: {available})"
)
shutil.copytree(source, target_dir / "skills" / name)
return str(target_dir)
26 changes: 20 additions & 6 deletions src/lh_harness/adapters/claude_permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,20 +82,34 @@ def path_deny_rules(paths: tuple[str, ...] | list[str]) -> tuple[str, ...]:
Deny rules still apply under `--dangerously-skip-permissions`, and a `Read`
deny also blocks the Edit tool. `//` anchors the pattern at the filesystem
root; anything else would resolve against the settings source.

On Windows an absolute path already starts with a drive letter, so the bare
`C:/...` form is emitted alongside the `//` one. Both are listed because a
deny rule that fails to match would silently expose the run's own logs and
prompts to the auditor, and an extra unmatched rule costs nothing.
"""
rules: list[str] = []
for raw in paths:
resolved = Path(raw).expanduser().resolve().as_posix().lstrip("/")
if not resolved:
posix = Path(raw).expanduser().resolve().as_posix()
stripped = posix.lstrip("/")
if not stripped:
continue
roots = [f"//{stripped}"]
if _has_drive_letter(posix):
roots.append(posix)
for tool in ("Read", "Edit"):
for pattern in (f"//{resolved}", f"//{resolved}/**"):
rule = f"{tool}({pattern})"
if rule not in rules:
rules.append(rule)
for root in roots:
for pattern in (root, f"{root}/**"):
rule = f"{tool}({pattern})"
if rule not in rules:
rules.append(rule)
return tuple(rules)


def _has_drive_letter(posix_path: str) -> bool:
return len(posix_path) >= 2 and posix_path[1] == ":" and posix_path[0].isalpha()


@dataclass(frozen=True)
class WorkspaceSnapshot:
records: dict[str, tuple[object, ...]]
Expand Down
Loading
Loading