mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-25 17:18:11 +00:00
The three subprocess tests spawn 'sys.executable -c' children that import hermes_cli. From a worktree, the child resolved the MAIN checkout's editable install instead of the tree under test, so the new DB/CLI guards appeared missing and the tests failed with rc=0. Route the spawns through a helper that pins the repo root under test on PYTHONPATH.
624 lines
21 KiB
Python
624 lines
21 KiB
Python
"""Regression tests for delegate_task isolation from parent Kanban workers."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shlex
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
# The subprocess-boundary tests below spawn ``sys.executable -c`` with a tmp
|
|
# cwd. Without an explicit PYTHONPATH the child resolves ``hermes_cli`` /
|
|
# ``agent`` through whatever install is on sys.path (in a worktree that is the
|
|
# MAIN checkout's editable install, which may not contain the code under
|
|
# test). Pin the repo root so the child always imports the tree being tested.
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def _python_with_repo_path(code: str) -> str:
|
|
"""Build a shell command running *code* with the repo under test on PYTHONPATH."""
|
|
return (
|
|
f"PYTHONPATH={shlex.quote(str(_REPO_ROOT))} "
|
|
f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}"
|
|
)
|
|
|
|
|
|
def _make_running_kanban_task(monkeypatch, tmp_path):
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
attachments_root = tmp_path / "attachments"
|
|
workspace = tmp_path / "parent-workspace"
|
|
workspace.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("HERMES_PROFILE", "parent-worker")
|
|
monkeypatch.setenv("HERMES_KANBAN_WORKSPACE", str(workspace))
|
|
monkeypatch.setenv("HERMES_KANBAN_ATTACHMENTS_ROOT", str(attachments_root))
|
|
|
|
from hermes_cli import kanban_db as kb
|
|
|
|
kb._INITIALIZED_PATHS.clear()
|
|
kb.init_db()
|
|
conn = kb.connect()
|
|
try:
|
|
tid = kb.create_task(
|
|
conn,
|
|
title="parent",
|
|
assignee="parent-worker",
|
|
workspace_kind="scratch",
|
|
workspace_path=str(workspace),
|
|
)
|
|
claim = kb.claim_task(conn, tid)
|
|
assert claim is not None
|
|
run_id = claim.id
|
|
finally:
|
|
conn.close()
|
|
|
|
monkeypatch.setenv("HERMES_KANBAN_TASK", tid)
|
|
monkeypatch.setenv("HERMES_KANBAN_RUN_ID", str(run_id))
|
|
return kb, tid, workspace, attachments_root
|
|
|
|
|
|
def test_delegated_child_context_suppresses_env_gated_kanban_tools(monkeypatch, tmp_path):
|
|
"""A delegate_task child must not inherit the parent's Kanban tool schema.
|
|
|
|
The parent process may be a dispatcher worker with HERMES_KANBAN_TASK set;
|
|
the child is only a subagent, not the run owner.
|
|
"""
|
|
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent")
|
|
monkeypatch.setenv("HERMES_KANBAN_RUN_ID", "123")
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
import tools.kanban_tools # noqa: F401 - ensure registered
|
|
from agent.delegation_context import delegated_child_context
|
|
from model_tools import _clear_tool_defs_cache, get_tool_definitions
|
|
from tools.registry import invalidate_check_fn_cache
|
|
|
|
invalidate_check_fn_cache()
|
|
_clear_tool_defs_cache()
|
|
with delegated_child_context():
|
|
schema = get_tool_definitions(enabled_toolsets=["terminal"], quiet_mode=True)
|
|
|
|
names = {s["function"].get("name") for s in schema if "function" in s}
|
|
assert "terminal" in names
|
|
assert {n for n in names if n and n.startswith("kanban_")} == set()
|
|
|
|
|
|
def test_build_child_agent_strips_kanban_toolset_even_when_parent_is_worker(monkeypatch):
|
|
"""Child construction must fail closed even if the parent exposes kanban."""
|
|
captured = {}
|
|
|
|
class FakeAgent:
|
|
def __init__(self, **kwargs):
|
|
captured.update(kwargs)
|
|
self.valid_tool_names = {"terminal"}
|
|
self.session_id = "child-session"
|
|
|
|
import run_agent
|
|
from tools import delegate_tool
|
|
|
|
monkeypatch.setattr(run_agent, "AIAgent", FakeAgent)
|
|
monkeypatch.setattr(delegate_tool, "_load_config", lambda: {})
|
|
|
|
class Parent:
|
|
enabled_toolsets = ["terminal", "kanban"]
|
|
valid_tool_names = {"terminal", "kanban_complete", "kanban_comment"}
|
|
model = "test-model"
|
|
provider = "test-provider"
|
|
base_url = "http://example.invalid"
|
|
api_mode = "chat_completions"
|
|
platform = "cli"
|
|
session_id = "parent-session"
|
|
|
|
child = delegate_tool._build_child_agent(
|
|
task_index=0,
|
|
goal="review only",
|
|
context=None,
|
|
toolsets=None,
|
|
model=None,
|
|
max_iterations=3,
|
|
task_count=1,
|
|
parent_agent=Parent(),
|
|
)
|
|
|
|
assert child.valid_tool_names == {"terminal"}
|
|
assert "kanban" not in captured["enabled_toolsets"]
|
|
assert "kanban" in captured["disabled_toolsets"]
|
|
|
|
|
|
def test_delegate_child_terminal_env_scrubs_parent_kanban_keys(monkeypatch):
|
|
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent")
|
|
monkeypatch.setenv("HERMES_KANBAN_RUN_ID", "123")
|
|
monkeypatch.setenv("HERMES_KANBAN_WORKSPACE", "/tmp/parent-workspace")
|
|
monkeypatch.setenv("HERMES_KANBAN_CLAIM_LOCK", "lock")
|
|
|
|
from agent.delegation_context import delegated_child_context
|
|
from tools.environments.local import _sanitize_subprocess_env
|
|
|
|
with delegated_child_context():
|
|
env = _sanitize_subprocess_env({
|
|
"HERMES_KANBAN_TASK": "t_parent",
|
|
"HERMES_KANBAN_RUN_ID": "123",
|
|
"HERMES_KANBAN_WORKSPACE": "/tmp/parent-workspace",
|
|
"HERMES_KANBAN_CLAIM_LOCK": "lock",
|
|
"PATH": "/usr/bin",
|
|
})
|
|
|
|
assert env["PATH"] == "/usr/bin"
|
|
assert "HERMES_KANBAN_TASK" not in env
|
|
assert "HERMES_KANBAN_RUN_ID" not in env
|
|
assert "HERMES_KANBAN_WORKSPACE" not in env
|
|
assert "HERMES_KANBAN_CLAIM_LOCK" not in env
|
|
assert env["HERMES_DELEGATED_CHILD_CONTEXT"] == "1"
|
|
|
|
|
|
def test_delegate_child_foreground_terminal_env_scrubs_parent_kanban_keys(monkeypatch):
|
|
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent")
|
|
monkeypatch.setenv("HERMES_KANBAN_RUN_ID", "123")
|
|
monkeypatch.setenv("HERMES_KANBAN_WORKSPACE", "/tmp/parent-workspace")
|
|
monkeypatch.setenv("HERMES_KANBAN_CLAIM_LOCK", "lock")
|
|
|
|
from agent.delegation_context import delegated_child_context
|
|
from tools.environments.local import _make_run_env
|
|
|
|
with delegated_child_context():
|
|
env = _make_run_env({"PATH": "/usr/bin"})
|
|
|
|
assert "HERMES_KANBAN_TASK" not in env
|
|
assert "HERMES_KANBAN_RUN_ID" not in env
|
|
assert "HERMES_KANBAN_WORKSPACE" not in env
|
|
assert "HERMES_KANBAN_CLAIM_LOCK" not in env
|
|
assert env["HERMES_DELEGATED_CHILD_CONTEXT"] == "1"
|
|
|
|
|
|
def test_delegate_child_process_marker_scrubs_foreground_terminal_kanban_keys(monkeypatch):
|
|
"""A delegated child subprocess has only the env marker, not the ContextVar."""
|
|
monkeypatch.setenv("HERMES_DELEGATED_CHILD_CONTEXT", "1")
|
|
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent")
|
|
monkeypatch.setenv("HERMES_KANBAN_RUN_ID", "123")
|
|
monkeypatch.setenv("HERMES_KANBAN_DB", "/tmp/parent-kanban.db")
|
|
monkeypatch.setenv("HERMES_KANBAN_WORKSPACE", "/tmp/parent-workspace")
|
|
monkeypatch.setenv("HERMES_KANBAN_CLAIM_LOCK", "lock")
|
|
|
|
from tools.environments.local import _make_run_env
|
|
|
|
env = _make_run_env({"PATH": "/usr/bin"})
|
|
|
|
assert "HERMES_KANBAN_TASK" not in env
|
|
assert "HERMES_KANBAN_RUN_ID" not in env
|
|
assert "HERMES_KANBAN_DB" not in env
|
|
assert "HERMES_KANBAN_WORKSPACE" not in env
|
|
assert "HERMES_KANBAN_CLAIM_LOCK" not in env
|
|
assert env["HERMES_DELEGATED_CHILD_CONTEXT"] == "1"
|
|
|
|
|
|
def test_delegate_child_execute_code_env_preserves_process_marker(monkeypatch, tmp_path):
|
|
"""execute_code has its own env scrubber; it must preserve child lineage."""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
from tools.code_execution_tool import _scrub_child_env
|
|
|
|
env = _scrub_child_env(
|
|
{
|
|
"HERMES_HOME": str(home),
|
|
"HERMES_DELEGATED_CHILD_CONTEXT": "1",
|
|
"HERMES_KANBAN_TASK": "t_parent",
|
|
"HERMES_KANBAN_RUN_ID": "123",
|
|
"HERMES_KANBAN_DB": str(home / "kanban.db"),
|
|
"HERMES_KANBAN_WORKSPACE": str(tmp_path / "parent-workspace"),
|
|
"PATH": "/usr/bin",
|
|
},
|
|
is_passthrough=lambda _: False,
|
|
is_windows=False,
|
|
)
|
|
|
|
assert env["HERMES_HOME"] == str(home)
|
|
assert env["HERMES_DELEGATED_CHILD_CONTEXT"] == "1"
|
|
assert env["PATH"] == "/usr/bin"
|
|
assert "HERMES_KANBAN_TASK" not in env
|
|
assert "HERMES_KANBAN_RUN_ID" not in env
|
|
assert "HERMES_KANBAN_DB" not in env
|
|
assert "HERMES_KANBAN_WORKSPACE" not in env
|
|
|
|
|
|
def test_delegate_child_execute_code_env_bridges_contextvar_and_scrubs_kanban(
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""The real execute_code child-env builder must bridge ContextVar lineage.
|
|
|
|
Regression coverage for the vulnerable path: delegate_task marks child
|
|
execution with a ContextVar, while execute_code used to scrub plain
|
|
``os.environ`` and therefore never wrote HERMES_DELEGATED_CHILD_CONTEXT into
|
|
the sandbox env.
|
|
"""
|
|
home = tmp_path / ".hermes"
|
|
home.mkdir()
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent")
|
|
monkeypatch.setenv("HERMES_KANBAN_RUN_ID", "123")
|
|
monkeypatch.setenv("HERMES_KANBAN_DB", str(home / "kanban.db"))
|
|
monkeypatch.setenv("HERMES_KANBAN_WORKSPACE", str(tmp_path / "parent-workspace"))
|
|
monkeypatch.setenv("HERMES_KANBAN_CLAIM_LOCK", "lock")
|
|
monkeypatch.delenv("HERMES_DELEGATED_CHILD_CONTEXT", raising=False)
|
|
|
|
from agent.delegation_context import delegated_child_context
|
|
from tools.code_execution_tool import _scrub_child_env
|
|
|
|
with delegated_child_context():
|
|
env = _scrub_child_env(
|
|
dict(os.environ),
|
|
is_passthrough=lambda k: k.startswith("HERMES_KANBAN_"),
|
|
is_windows=False,
|
|
)
|
|
|
|
assert os.environ.get("HERMES_DELEGATED_CHILD_CONTEXT") is None
|
|
assert env["HERMES_HOME"] == str(home)
|
|
assert env["HERMES_DELEGATED_CHILD_CONTEXT"] == "1"
|
|
assert "HERMES_KANBAN_TASK" not in env
|
|
assert "HERMES_KANBAN_RUN_ID" not in env
|
|
assert "HERMES_KANBAN_DB" not in env
|
|
assert "HERMES_KANBAN_WORKSPACE" not in env
|
|
assert "HERMES_KANBAN_CLAIM_LOCK" not in env
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="execute_code UDS sandbox is POSIX-only")
|
|
def test_delegate_child_execute_code_cannot_complete_parent_by_importing_kanban_db(
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""E2E: execute_code sandbox inherits child lineage, not parent Kanban env."""
|
|
kb, tid, _workspace, _attachments_root = _make_running_kanban_task(
|
|
monkeypatch,
|
|
tmp_path,
|
|
)
|
|
|
|
from agent.delegation_context import delegated_child_context
|
|
from tools import code_execution_tool as cet
|
|
|
|
code = "\n".join([
|
|
"import json, os, sqlite3",
|
|
"from pathlib import Path",
|
|
"from agent.delegation_context import is_delegated_child_process_context",
|
|
"from hermes_cli import kanban_db as kb",
|
|
"observed = {",
|
|
" 'marker': os.environ.get('HERMES_DELEGATED_CHILD_CONTEXT'),",
|
|
" 'is_child': is_delegated_child_process_context(),",
|
|
" 'kanban_keys': sorted(k for k in os.environ if k.startswith('HERMES_KANBAN_')),",
|
|
"}",
|
|
"conn = sqlite3.connect(Path(os.environ['HERMES_HOME']) / 'kanban.db')",
|
|
"conn.row_factory = sqlite3.Row",
|
|
"try:",
|
|
f" kb.complete_task(conn, {tid!r}, summary='child db bypass')",
|
|
"except PermissionError as exc:",
|
|
" observed['permission_error'] = str(exc)",
|
|
"else:",
|
|
" observed['permission_error'] = None",
|
|
"finally:",
|
|
" conn.close()",
|
|
"print(json.dumps(observed, sort_keys=True))",
|
|
])
|
|
|
|
monkeypatch.setattr(
|
|
"tools.approval.check_execute_code_guard",
|
|
lambda *_args, **_kwargs: {"approved": True},
|
|
)
|
|
monkeypatch.setattr(
|
|
cet,
|
|
"_load_config",
|
|
lambda: {"timeout": 15, "max_tool_calls": 50, "mode": "strict"},
|
|
)
|
|
|
|
with delegated_child_context():
|
|
raw = cet.execute_code(code, task_id="child-execute-code", enabled_tools=[])
|
|
|
|
payload = json.loads(raw)
|
|
assert payload["status"] == "success", payload.get("error", "")
|
|
observed = json.loads(payload["output"].strip())
|
|
assert observed["marker"] == "1"
|
|
assert observed["is_child"] is True
|
|
assert observed["kanban_keys"] == []
|
|
assert "delegate_task child contexts cannot mutate Kanban" in observed["permission_error"]
|
|
|
|
conn = kb.connect()
|
|
try:
|
|
task = kb.get_task(conn, tid)
|
|
run = kb.latest_run(conn, tid)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert task.status == "running"
|
|
assert run.status == "running"
|
|
|
|
|
|
def test_delegated_child_subprocess_env_preserves_inherit_semantics_until_needed(monkeypatch):
|
|
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent")
|
|
monkeypatch.setenv("HERMES_KANBAN_DB", "/tmp/parent-kanban.db")
|
|
|
|
from agent.delegation_context import (
|
|
delegated_child_context,
|
|
delegated_child_subprocess_env,
|
|
)
|
|
|
|
assert delegated_child_subprocess_env() is None
|
|
|
|
with delegated_child_context():
|
|
env = delegated_child_subprocess_env()
|
|
|
|
assert env is not None
|
|
assert env["HERMES_DELEGATED_CHILD_CONTEXT"] == "1"
|
|
assert "HERMES_KANBAN_TASK" not in env
|
|
assert "HERMES_KANBAN_DB" not in env
|
|
|
|
|
|
def test_delegate_child_local_execute_cannot_complete_parent_via_kanban_cli(
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
kb, tid, _workspace, _attachments_root = _make_running_kanban_task(
|
|
monkeypatch,
|
|
tmp_path,
|
|
)
|
|
|
|
from agent.delegation_context import delegated_child_context
|
|
from tools.environments.local import LocalEnvironment
|
|
|
|
code = (
|
|
"from hermes_cli import kanban; "
|
|
"import argparse; "
|
|
"p=argparse.ArgumentParser(); "
|
|
"sub=p.add_subparsers(dest='cmd'); "
|
|
"kanban.build_parser(sub); "
|
|
f"args=p.parse_args(['kanban','complete',{tid!r},'--summary','child cli bypass']); "
|
|
"raise SystemExit(kanban.kanban_command(args))"
|
|
)
|
|
env = LocalEnvironment(cwd=str(tmp_path), timeout=15)
|
|
try:
|
|
with delegated_child_context():
|
|
result = env.execute(
|
|
_python_with_repo_path(code),
|
|
timeout=15,
|
|
)
|
|
finally:
|
|
env.cleanup()
|
|
|
|
assert result["returncode"] == 1
|
|
assert "delegate_task child contexts cannot mutate Kanban tasks" in result["output"]
|
|
|
|
conn = kb.connect()
|
|
try:
|
|
task = kb.get_task(conn, tid)
|
|
run = kb.latest_run(conn, tid)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert task.status == "running"
|
|
assert run.status == "running"
|
|
|
|
|
|
def test_delegate_child_subprocess_cannot_complete_parent_by_importing_kanban_db(
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""The DB mutation layer, not only the CLI/tool handlers, is guarded."""
|
|
kb, tid, _workspace, _attachments_root = _make_running_kanban_task(
|
|
monkeypatch,
|
|
tmp_path,
|
|
)
|
|
|
|
from agent.delegation_context import delegated_child_context
|
|
from tools.environments.local import LocalEnvironment
|
|
|
|
code = (
|
|
"import os, sqlite3; "
|
|
"from pathlib import Path; "
|
|
"from hermes_cli import kanban_db as kb; "
|
|
"conn=sqlite3.connect(Path(os.environ['HERMES_HOME']) / 'kanban.db'); "
|
|
"conn.row_factory=sqlite3.Row; "
|
|
"\ntry:\n"
|
|
f" kb.complete_task(conn, {tid!r}, summary='child db bypass')\n"
|
|
"except Exception as exc:\n"
|
|
" print(type(exc).__name__ + ': ' + str(exc))\n"
|
|
" raise SystemExit(7)\n"
|
|
"else:\n"
|
|
" raise SystemExit(0)\n"
|
|
)
|
|
env = LocalEnvironment(cwd=str(tmp_path), timeout=15)
|
|
try:
|
|
with delegated_child_context():
|
|
result = env.execute(
|
|
_python_with_repo_path(code),
|
|
timeout=15,
|
|
)
|
|
finally:
|
|
env.cleanup()
|
|
|
|
assert result["returncode"] == 7
|
|
assert "delegate_task child contexts cannot mutate Kanban tasks or boards" in result["output"]
|
|
|
|
conn = kb.connect()
|
|
try:
|
|
task = kb.get_task(conn, tid)
|
|
run = kb.latest_run(conn, tid)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert task.status == "running"
|
|
assert run.status == "running"
|
|
|
|
|
|
def test_delegate_child_kanban_cli_cannot_delete_parent_board(
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
kb, _tid, _workspace, _attachments_root = _make_running_kanban_task(
|
|
monkeypatch,
|
|
tmp_path,
|
|
)
|
|
kb.create_board("victim")
|
|
assert kb.board_exists("victim")
|
|
|
|
from agent.delegation_context import delegated_child_context
|
|
from tools.environments.local import LocalEnvironment
|
|
|
|
code = (
|
|
"from hermes_cli import kanban; "
|
|
"import argparse; "
|
|
"p=argparse.ArgumentParser(); "
|
|
"sub=p.add_subparsers(dest='cmd'); "
|
|
"kanban.build_parser(sub); "
|
|
"args=p.parse_args(['kanban','boards','rm','victim','--delete']); "
|
|
"raise SystemExit(kanban.kanban_command(args))"
|
|
)
|
|
env = LocalEnvironment(cwd=str(tmp_path), timeout=15)
|
|
try:
|
|
with delegated_child_context():
|
|
result = env.execute(
|
|
_python_with_repo_path(code),
|
|
timeout=15,
|
|
)
|
|
finally:
|
|
env.cleanup()
|
|
|
|
assert result["returncode"] == 1
|
|
assert "delegate_task child contexts cannot mutate Kanban tasks" in result["output"]
|
|
assert kb.board_exists("victim")
|
|
assert kb.board_dir("victim").is_dir()
|
|
|
|
|
|
def test_delegate_child_kanban_mutator_guard_rejects_explicit_task_id(monkeypatch):
|
|
"""Defense in depth: direct handler access still cannot mutate a board."""
|
|
monkeypatch.setenv("HERMES_KANBAN_TASK", "t_parent")
|
|
from agent.delegation_context import delegated_child_context
|
|
from tools import kanban_tools
|
|
|
|
with delegated_child_context():
|
|
raw = kanban_tools._handle_complete({
|
|
"task_id": "t_parent",
|
|
"summary": "should not complete",
|
|
})
|
|
|
|
payload = json.loads(raw)
|
|
assert payload["error"]
|
|
assert "delegate_task child" in payload["error"]
|
|
|
|
|
|
def test_delegate_child_attach_guard_leaves_no_row_or_file(monkeypatch, tmp_path):
|
|
kb, tid, _workspace, attachments_root = _make_running_kanban_task(monkeypatch, tmp_path)
|
|
|
|
from agent.delegation_context import delegated_child_context
|
|
from tools import kanban_tools
|
|
|
|
with delegated_child_context():
|
|
raw = kanban_tools._handle_attach({
|
|
"task_id": tid,
|
|
"filename": "leak.txt",
|
|
"content_base64": "bGVhay1ieXRlcw==",
|
|
"content_type": "text/plain",
|
|
})
|
|
|
|
payload = json.loads(raw)
|
|
assert payload["error"]
|
|
assert "delegate_task child" in payload["error"]
|
|
|
|
conn = kb.connect()
|
|
try:
|
|
assert kb.list_attachments(conn, tid) == []
|
|
finally:
|
|
conn.close()
|
|
task_dir = attachments_root / tid
|
|
assert not task_dir.exists() or list(task_dir.iterdir()) == []
|
|
|
|
|
|
def test_delegate_child_attach_url_guard_leaves_no_row_or_file(monkeypatch, tmp_path):
|
|
kb, tid, _workspace, attachments_root = _make_running_kanban_task(monkeypatch, tmp_path)
|
|
|
|
from agent.delegation_context import delegated_child_context
|
|
from tools import kanban_tools
|
|
|
|
def forbidden_download(*_args, **_kwargs):
|
|
raise AssertionError("delegated child guard must run before URL download")
|
|
|
|
monkeypatch.setattr(kanban_tools, "_download_url_with_cap", forbidden_download)
|
|
|
|
with delegated_child_context():
|
|
raw = kanban_tools._handle_attach_url({
|
|
"task_id": tid,
|
|
"url": "https://example.com/leak.txt",
|
|
})
|
|
|
|
payload = json.loads(raw)
|
|
assert payload["error"]
|
|
assert "delegate_task child" in payload["error"]
|
|
|
|
conn = kb.connect()
|
|
try:
|
|
assert kb.list_attachments(conn, tid) == []
|
|
finally:
|
|
conn.close()
|
|
task_dir = attachments_root / tid
|
|
assert not task_dir.exists() or list(task_dir.iterdir()) == []
|
|
|
|
|
|
def test_child_attempting_default_complete_does_not_finish_parent_or_delete_workspace(
|
|
monkeypatch,
|
|
tmp_path,
|
|
):
|
|
"""Deterministic E2E: a delegated child cannot complete its parent task."""
|
|
kb, tid, workspace, _attachments_root = _make_running_kanban_task(monkeypatch, tmp_path)
|
|
from tools import delegate_tool
|
|
from tools import kanban_tools
|
|
|
|
class Parent:
|
|
_current_task_id = tid
|
|
|
|
def _touch_activity(self, _desc):
|
|
return None
|
|
|
|
class Child:
|
|
tool_progress_callback = None
|
|
_delegate_saved_tool_names = []
|
|
_credential_pool = None
|
|
_subagent_id = "sa-test"
|
|
_delegate_depth = 1
|
|
_parent_subagent_id = None
|
|
model = "test-model"
|
|
session_prompt_tokens = 0
|
|
session_completion_tokens = 0
|
|
session_estimated_cost_usd = 0.0
|
|
session_reasoning_tokens = 0
|
|
|
|
def get_activity_summary(self):
|
|
return {"api_call_count": 0, "max_iterations": 1, "current_tool": None}
|
|
|
|
def run_conversation(self, user_message, task_id, **_kwargs):
|
|
attempted = kanban_tools._handle_complete({"summary": "wrong child completion"})
|
|
return {
|
|
"final_response": attempted,
|
|
"completed": True,
|
|
"api_calls": 0,
|
|
"messages": [],
|
|
}
|
|
|
|
def close(self):
|
|
return None
|
|
|
|
result = delegate_tool._run_single_child(0, "try to complete parent", Child(), Parent())
|
|
|
|
conn = kb.connect()
|
|
try:
|
|
task = kb.get_task(conn, tid)
|
|
run = kb.latest_run(conn, tid)
|
|
finally:
|
|
conn.close()
|
|
|
|
assert result["status"] == "completed"
|
|
assert "delegate_task child" in result["summary"]
|
|
assert task.status == "running"
|
|
assert run.status == "running"
|
|
assert workspace.is_dir()
|