Skip to content
Open
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
30 changes: 20 additions & 10 deletions src/coder_eval/agents/antigravity_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
TurnEndStatus,
TurnStartEvent,
)
from coder_eval.utils import expand_env_vars
from coder_eval.utils import AGENT_ENV_SCRUB_PREFIXES, AGENT_ENV_SCRUB_VARS, expand_env_vars


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -389,22 +389,32 @@ async def _harness_spawn_guard(self) -> AsyncIterator[None]:
never affects the live harness. The lock is taken even when no prepend dirs
were configured: a no-prepend spawn must still wait out any in-flight mutated-
PATH window, or its harness would inherit another task's mock dirs.

The same spawn window scrubs harness-internal vars (SKILLS_REPO_PATH — the
raw skills checkout with grading material — and CODER_EVAL_*) out of
``os.environ`` so the harness subprocess never inherits them; they are
restored for the harness process itself in the same ``finally``.
"""
async with _harness_spawn_lock():
if not self._env_path_prepend:
yield
return
scrubbed = {
k: os.environ.pop(k)
for k in list(os.environ)
if k in AGENT_ENV_SCRUB_VARS or k.startswith(AGENT_ENV_SCRUB_PREFIXES)
}
path_key = next((k for k in os.environ if k.upper() == "PATH"), "PATH")
original = os.environ.get(path_key)
os.environ[path_key] = os.pathsep.join([*self._env_path_prepend, original or ""])
self._log.debug("PATH prepend for harness spawn: %s", os.pathsep.join(self._env_path_prepend))
if self._env_path_prepend:
os.environ[path_key] = os.pathsep.join([*self._env_path_prepend, original or ""])
self._log.debug("PATH prepend for harness spawn: %s", os.pathsep.join(self._env_path_prepend))
try:
yield
finally:
if original is None:
os.environ.pop(path_key, None)
else:
os.environ[path_key] = original
os.environ.update(scrubbed)
if self._env_path_prepend:
if original is None:
os.environ.pop(path_key, None)
else:
os.environ[path_key] = original

async def communicate(
self,
Expand Down
9 changes: 7 additions & 2 deletions src/coder_eval/agents/claude_code_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
TurnEndStatus,
TurnStartEvent,
)
from coder_eval.utils import dump_dataclass, process_plugins
from coder_eval.utils import dump_dataclass, process_plugins, scrub_agent_env_overrides


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -765,7 +765,12 @@ def _build_sdk_env(
Returns:
Tuple of (env_vars_dict, model_override_or_None).
"""
base_env: dict[str, str] = {}
# Defence-in-depth: the SDK spawns the CLI with {**os.environ,
# **options.env}, so harness-internal vars (SKILLS_REPO_PATH — the raw
# skills checkout with grading material — and CODER_EVAL_*) reach the
# agent unless explicitly overridden here. The grading env
# (Sandbox._build_run_command_env) keeps them.
base_env: dict[str, str] = scrub_agent_env_overrides()
if path := os.environ.get("PATH"):
base_env["PATH"] = path

Expand Down
9 changes: 6 additions & 3 deletions src/coder_eval/agents/codex_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
TurnEndStatus,
TurnStartEvent,
)
from coder_eval.utils import expand_env_vars
from coder_eval.utils import expand_env_vars, scrub_agent_env_overrides


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -1089,9 +1089,12 @@ def _build_codex_env(self) -> dict[str, str] | None:

The SDK merges this dict over ``os.environ`` for the app-server process
(and normalizes the PATH key case-insensitively), so a full PATH value
here safely replaces the inherited one.
here safely replaces the inherited one. That same merge is why the
harness-internal vars (SKILLS_REPO_PATH / CODER_EVAL_*) are masked with
explicit empty-string overrides — omitting them would leave the
inherited values visible to the agent.
"""
env: dict[str, str] = {}
env: dict[str, str] = scrub_agent_env_overrides()
api_key = os.getenv("CODEX_API_KEY")
if api_key:
env["CODEX_API_KEY"] = api_key
Expand Down
38 changes: 38 additions & 0 deletions src/coder_eval/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import asyncio
import logging
import os
import re
import time
import uuid
Expand Down Expand Up @@ -59,6 +60,7 @@
from .orchestration.early_stop import EarlyStopWatcher, early_stop_active, validate_early_stop
from .orchestration.evaluation import load_reference
from .path_utils import format_task_log_id, task_log_path
from .plugin_bundle import stage_agent_plugins
from .sandbox import Sandbox
from .simulation import DialogStopReason, SimulatorResult, UserSimulator, evaluate_stop
from .streaming.callbacks import CompositeStreamCallback, StreamCallback, TaskScopedCallback, safe_emit
Expand Down Expand Up @@ -1077,6 +1079,13 @@ async def _setup_sandbox() -> Any:
logger.info("API routing: %s", _format_routing(self.route, self.task.agent.model if self.task.agent else None))
self.success_checker = SuccessChecker(self.sandbox, route=self.eval_route)

# Stage agent-visible plugin bundles BEFORE the agent is created:
# agent.plugins[].path is rewritten from the raw source checkout (which
# carries graders / reference answers) to a verified, file-allowlisted
# bundle. Grading keeps the raw path — run_command criteria resolve
# $SKILLS_REPO_PATH from the untouched sandbox/process env.
await self._stage_agent_plugin_bundles()

# Create and start the agent. For a no-op (type: none) task this dispatches
# to NoOpAgent, whose start/communicate/stop are no-ops — the orchestrator
# runs the normal lifecycle without any agentless branching, and the
Expand Down Expand Up @@ -1254,6 +1263,35 @@ def _refresh_runtime_tool_versions(self) -> None:
except Exception as exc:
logger.debug("Failed to refresh runtime tool versions: %s", exc)

async def _stage_agent_plugin_bundles(self) -> None:
"""Rewrite ``agent.plugins[].path`` to verified agent-visible bundles.

The bundle is built once per resolved source path per run and
digest-verified on EVERY task before its agent starts (see
:mod:`coder_eval.plugin_bundle`); a drifted bundle raises
``PluginBundleError``, which lands the task as ``FinalStatus.ERROR``
with component ``orchestrator.setup`` — it never falls back to the raw
path. Each source's manifest digest is recorded on
``environment_info["plugin_bundles"]`` for audit.

Skipped inside docker task containers (``CODER_EVAL_IN_CONTAINER``):
the docker driver owns its own host-side plugin staging/mount surface
(isolation/docker_runner.py), and re-bundling in-container would add a
per-container recursive copy without changing what the host mounted.
"""
agent_cfg = self.task.agent
if agent_cfg is None or not agent_cfg.plugins:
return
if os.environ.get("CODER_EVAL_IN_CONTAINER"):
return
staged, digests = await asyncio.to_thread(
stage_agent_plugins,
agent_cfg.plugins, # type: ignore[arg-type]
)
agent_cfg.plugins = staged # type: ignore[assignment]
if digests and self.result is not None:
self.result.environment_info["plugin_bundles"] = digests

async def _create_agent(self) -> Agent[Any]:
"""Create the appropriate agent based on task configuration.

Expand Down
Loading
Loading