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
9 changes: 7 additions & 2 deletions docs/TASK_DEFINITION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -558,7 +558,7 @@ sandbox:

The agent receives a thin `cli_mocks/uip` wrapper. The fixture is copied into a per-run staging directory, mounted below the private `mockd` filesystem parent, and read only by the `mockd` UID. The client speaks a bounded Unix-socket protocol and has no file-read, path, glob, search, dump, or debug operation. Calls use the existing `cli_mocks/calls.jsonl` schema.

Fixture files map exact argument lists to responses:
Fixture files map argument lists to responses:

```json
{
Expand All @@ -578,7 +578,12 @@ Fixture files map exact argument lists to responses:
}
```

Matching defaults to exact argv equality. A response may opt into `"match_mode": "normalized"`; this still selects from a finite command map but ignores `--output <format>`, treats `--flag=value` like `--flag value`, and permits token reordering. It never performs subset or substring matching. Duplicate keys, malformed responses, oversized output, request-budget exhaustion, and service startup failures all fail loudly.
Matching defaults to exact argv equality. Two further modes exist, selected per response via `match_mode`:

- `"normalized"` still selects from a finite command map but ignores `--output <format>`, treats `--flag=value` like `--flag value`, and permits token reordering. Duplicate keys are rejected at load for both finite modes.
- `"subset"` matches when every rule token appears in the invocation's normalized token set, regardless of order or extra arguments. Subset rules are evaluated in fixture-file order and the first match wins; exact and normalized matches always take precedence over subset scanning. Duplicate subset rules are allowed (an earlier rule shadows a later one); an empty subset `argv` is rejected at load.

Malformed responses, oversized output, request-budget exhaustion, and service startup failures all fail loudly.

`passthrough_argv_prefixes` is for deliberately public live operations such as `uip docsai ask`. `mockd` invokes the real tool only when argv begins with one of these typed prefixes, caches the response in memory for the run, and never reveals the executable path to the agent. Do not use a broad prefix such as `[or]` or `[auth]`. `protected_mocks` and `record_cli` cannot claim the same tool name.

Expand Down
67 changes: 58 additions & 9 deletions src/coder_eval/protected_mock/runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,91 @@
import os
import subprocess
import sys
import tempfile
import time
from collections.abc import Iterator
from pathlib import Path

from .protocol import SERVER_LAUNCHER, SOCKET_PATH


# Generous on purpose: normal startup is well under a second, but a loaded box
# -- parallel workers, cold caches -- can stall interpreter startup and bind
# well past a tight deadline. The common case is unaffected: the poll returns as
# soon as the socket appears.
STARTUP_TIMEOUT_SECONDS = 30.0


def _server_stderr_suffix(stderr_path: Path) -> str:
"""Tail of the child's captured stderr, formatted for an error message."""
try:
text = stderr_path.read_text(encoding="utf-8", errors="replace").strip()
except OSError:
return ""
if not text:
return ""
return f"; server stderr (tail): {text[-2000:]}"


@contextlib.contextmanager
def running_mock_server(config_path: Path | None) -> Iterator[None]:
if config_path is None:
yield
return

process = subprocess.Popen(
[SERVER_LAUNCHER, sys.executable, "-m", "coder_eval.protected_mock.server", "--config", str(config_path)],
stdin=subprocess.DEVNULL,
)
# Captured to a file rather than a pipe: nothing drains a pipe here, and a
# full one would deadlock the child. The temp file is created 0600 and owned
# by the spawning process (root inside the container), so the agent uid
# cannot read it. It deliberately lives outside the socket directory, which
# mockd creates for itself.
with tempfile.NamedTemporaryFile(prefix="coder-eval-mockd-", suffix=".stderr", delete=False) as stderr_sink:
stderr_path = Path(stderr_sink.name)
try:
process = subprocess.Popen(
[
SERVER_LAUNCHER,
sys.executable,
"-m",
"coder_eval.protected_mock.server",
"--config",
str(config_path),
],
stdin=subprocess.DEVNULL,
stderr=stderr_sink,
)
except OSError:
stderr_path.unlink(missing_ok=True)
raise

socket_path = Path(SOCKET_PATH)
try:
deadline = time.monotonic() + 5
started = time.monotonic()
deadline = started + STARTUP_TIMEOUT_SECONDS
while time.monotonic() < deadline:
if process.poll() is not None:
raise RuntimeError(f"protected mockd exited during startup with code {process.returncode}")
raise RuntimeError(
f"protected mockd exited during startup with code {process.returncode}"
+ _server_stderr_suffix(stderr_path)
)
if socket_path.exists():
break
time.sleep(0.02)
else:
raise RuntimeError("protected mockd did not create its socket within 5 seconds")
waited = time.monotonic() - started
deadline_note = f"within {waited:.1f}s (deadline {STARTUP_TIMEOUT_SECONDS}s)"
raise RuntimeError(
f"protected mockd did not create its socket {deadline_note}" + _server_stderr_suffix(stderr_path)
)
yield
finally:
if process.poll() is None:
process.terminate()
try:
process.wait(timeout=3)
process.wait(timeout=5)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=3)
process.wait(timeout=5)
with contextlib.suppress(OSError):
os.unlink(socket_path)
with contextlib.suppress(OSError):
os.unlink(stderr_path)
57 changes: 45 additions & 12 deletions src/coder_eval/protected_mock/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class CommandResponse:
class ToolState:
responses: dict[tuple[str, ...], CommandResponse]
normalized_responses: dict[tuple[str, ...], CommandResponse]
subset_responses: list[tuple[tuple[str, ...], CommandResponse]]
default: CommandResponse
remaining: int
passthrough_prefixes: tuple[tuple[str, ...], ...]
Expand All @@ -42,30 +43,42 @@ class ToolState:
_NOISE_VALUE_FLAGS = frozenset({"--output"})


def _normalized_argv(argv: list[str]) -> tuple[str, ...]:
"""Canonical finite-command key: flag form/order agnostic, never subset matching."""
def _expand_argv_tokens(argv: list[str]) -> list[str]:
"""Flag-form-agnostic token stream: ``--flag=value`` split, noise flags dropped."""

expanded: list[str] = []
for raw in argv:
if raw.startswith("-") and "=" in raw:
flag, value = raw.split("=", 1)
if flag in _NOISE_VALUE_FLAGS:
# Inline form is dropped whole (value included, even an empty
# one) so a bare noise flag never re-enters the skip logic below.
continue
expanded.append(flag)
if value:
expanded.append(value)
else:
expanded.append(raw)

cleaned: list[str] = []
skip_next = False
for token in expanded:
if skip_next:
skip_next = False
continue
index = 0
while index < len(expanded):
token = expanded[index]
index += 1
if token in _NOISE_VALUE_FLAGS:
skip_next = True
# Split form: swallow the following token only when it is actually a
# value, so a trailing noise flag cannot eat the next flag.
if index < len(expanded) and not expanded[index].startswith("-"):
index += 1
continue
cleaned.append(token)
return tuple(sorted(cleaned))
return cleaned


def _normalized_argv(argv: list[str]) -> tuple[str, ...]:
"""Canonical finite-command key: flag form/order agnostic, never subset matching."""

return tuple(sorted(_expand_argv_tokens(argv)))


def _response(raw: object, *, context: str) -> CommandResponse:
Expand Down Expand Up @@ -98,16 +111,26 @@ def _load_tool(
raise ValueError(f"fixture {fixture_path} responses must be a list")
responses: dict[tuple[str, ...], CommandResponse] = {}
normalized_responses: dict[tuple[str, ...], CommandResponse] = {}
# Ordered on purpose: subset rules are scanned in fixture-file order and the
# first match wins, so duplicates are legal (an earlier rule shadows a later
# one) -- the duplicate-key error applies to the finite match modes only.
subset_responses: list[tuple[tuple[str, ...], CommandResponse]] = []
for index, entry in enumerate(entries):
if not isinstance(entry, dict):
raise ValueError(f"fixture {fixture_path} response {index} must be an object")
argv = entry.get("argv")
if not isinstance(argv, list) or not all(isinstance(item, str) for item in argv):
raise ValueError(f"fixture {fixture_path} response {index}.argv must be a string list")
key = tuple(argv)
match_mode = entry.get("match_mode", "exact")
if match_mode not in {"exact", "normalized"}:
raise ValueError(f"fixture {fixture_path} response {index}.match_mode must be exact or normalized")
if match_mode not in {"exact", "normalized", "subset"}:
raise ValueError(f"fixture {fixture_path} response {index}.match_mode must be exact, normalized, or subset")
if match_mode == "subset":
rule_tokens = tuple(_expand_argv_tokens(argv))
if not argv or not rule_tokens:
raise ValueError(f"fixture {fixture_path} response {index}.argv must be non-empty for subset matching")
subset_responses.append((rule_tokens, _response(entry, context=f"response {index}")))
continue
key = tuple(argv)
destination = responses if match_mode == "exact" else normalized_responses
command_key = key if match_mode == "exact" else _normalized_argv(argv)
if command_key in destination:
Expand All @@ -126,6 +149,7 @@ def _load_tool(
return ToolState(
responses=responses,
normalized_responses=normalized_responses,
subset_responses=subset_responses,
default=default,
remaining=max_requests,
passthrough_prefixes=tuple(tuple(prefix) for prefix in passthrough_prefixes),
Expand Down Expand Up @@ -187,6 +211,15 @@ def dispatch(self, tool: str, argv: list[str]) -> CommandResponse:
response = state.responses.get(tuple(argv))
if response is None:
response = state.normalized_responses.get(_normalized_argv(argv))
if response is None and state.subset_responses:
# Finite matches take precedence; subset rules scan in fixture-file
# order and the first whose tokens all appear in the invocation's
# normalized token set wins.
invocation_tokens = set(_expand_argv_tokens(argv))
for rule_tokens, candidate in state.subset_responses:
if all(token in invocation_tokens for token in rule_tokens):
response = candidate
break
if response is not None:
return response
if any(tuple(argv[: len(prefix)]) == prefix for prefix in state.passthrough_prefixes):
Expand Down
Loading