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
15 changes: 15 additions & 0 deletions apodex/agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,21 @@ def localize_path_args(name: str, args: dict, cwd: str) -> dict | None:
except Exception:
continue
if not (rel == ".." or rel.startswith(".." + os.sep)):
# Native workflow mode deliberately separates the user's project
# (cwd) from its run-private execution workspace. The workflow
# read_file resolves relative paths in that private workspace, so
# converting a correct absolute project path to "README.md" would
# make it read the wrong filesystem location.
runtime_workspace = os.environ.get(
"FRONTIER_AGENT_WORKSPACE_DIR", ""
).strip()
if name == "read_file" and runtime_workspace:
try:
workspace_real = os.path.realpath(runtime_workspace)
except Exception:
workspace_real = ""
if workspace_real and workspace_real != cwd_real:
return None
new = dict(args)
new[key] = rel or "."
return new
Expand Down
29 changes: 25 additions & 4 deletions apodex/task_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,17 @@ def _is_complete_run(
# phase hit its soft deadline. Its explicit complete status is authoritative.
if answer_status == "complete" and answer_source == "reporter_llm":
return True
# Main-agent workflows may finish with a plain-text assistant turn, which
# the loop records as ``no_tool``. Once the workflow has explicitly marked
# that agent-produced answer complete, treat that terminal as successful.
# Keep this narrower than accepting workflow ``no_tool`` in general: an
# exhausted no-tool nudge budget can also use the same stop reason.
if (
stopped_by == _NO_TOOL_STOP
and answer_status == "complete"
and answer_source == "agent"
):
return True
if no_tool_is_complete and stopped_by == _NO_TOOL_STOP:
return True
return stopped_by in _COMPLETE_TOP_LEVEL_STOPS
Expand Down Expand Up @@ -565,18 +576,28 @@ async def _run_native_workflow(self, task: str, profile: Any) -> None:
answer_status=str(state.get("answer_status") or ""),
answer_source=str(state.get("final_answer_source") or ""),
)
turns_used = (
int(state.get("turns_used") or 0)
if "turns_used" in state
else len(state.get("react_steps") or [])
)
tool_calls_count = (
int(state.get("tool_calls_count") or 0)
if "tool_calls_count" in state
else 0
)
if complete:
self.r.final(
final,
turns=len(state.get("react_steps") or []),
tool_calls=0,
turns=turns_used,
tool_calls=tool_calls_count,
stopped_by=stopped_by,
)
else:
self._show_incomplete_run(
final,
turns=len(state.get("react_steps") or []),
tool_calls=0,
turns=turns_used,
tool_calls=tool_calls_count,
stopped_by=stopped_by,
)
await self._render_changed_files()
Expand Down
67 changes: 67 additions & 0 deletions apodex/tests/test_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,27 @@ def test_localize_absolute_path_inside_cwd(tmp_path):
assert out is not None and out["path"] == "sub/f.py"


def test_read_file_keeps_project_absolute_path_with_split_runtime_workspace(
tmp_path, monkeypatch,
):
project = tmp_path / "project"
runtime_workspace = tmp_path / "private-workspace"
project.mkdir()
runtime_workspace.mkdir()
target = project / "README.md"
target.write_text("# project\n")

monkeypatch.setenv(
"FRONTIER_AGENT_WORKSPACE_DIR", str(runtime_workspace),
)

out = localize_path_args(
"read_file", {"path": str(target)}, str(project),
)

assert out is None


def test_localize_preserves_absolute_task_input_path(tmp_path, monkeypatch):
inputs = tmp_path / ".apodex" / "inputs" / "run"
inputs.mkdir(parents=True)
Expand Down Expand Up @@ -931,6 +952,8 @@ def test_native_workflow_no_tool_stop_is_not_reported_as_delivery(
assert "no_tool" in out
assert "partial output was not saved as a final report" in out
assert "Final report" not in out
assert "turns=12" in out
assert "tools=0" in out


def test_generic_loop_no_tool_stop_is_a_normal_finish():
Expand All @@ -942,6 +965,33 @@ def test_generic_loop_no_tool_stop_is_a_normal_finish():
assert _is_complete_run("max_turns", no_tool_is_complete=True) is False


def test_workflow_complete_agent_no_tool_is_a_normal_finish():
"""A workflow-certified agent answer may terminate via a tool-free turn."""
from apodex.task_runner import _is_complete_run

assert _is_complete_run(
"no_tool",
answer_status="complete",
answer_source="agent",
) is True

# Do not broaden workflow no_tool into an unconditional success signal.
assert _is_complete_run(
"no_tool",
answer_status="best_effort",
answer_source="agent",
) is False
assert _is_complete_run(
"no_tool",
answer_source="agent",
) is False
assert _is_complete_run(
"max_turns",
answer_status="complete",
answer_source="agent",
) is False


async def _drive_workflow(session, profile, follow_up):
"""Run one native workflow with ``run_task`` stubbed to record follow-ups."""
session.run_task = follow_up # type: ignore[method-assign]
Expand Down Expand Up @@ -1528,3 +1578,20 @@ def test_download_file_target_is_the_resolved_destination(monkeypatch, tmp_path)
assert named.startswith(str(tmp_path / "downloads" / "p.pdf"))
assert "/elsewhere/" not in named # the requested directory is ignored
assert "renamed" in named # collisions rename it


def test_native_workflow_uses_authoritative_loop_telemetry(
tmp_path, monkeypatch, capsys,
):
_run_workflow_returning({
"final_answer": "done",
"answer_status": "complete",
"final_answer_source": "agent",
"stopped_by": "no_tool",
"react_steps": [{}] * 7,
"turns_used": 8,
"tool_calls_count": 7,
}, tmp_path, monkeypatch)

out = capsys.readouterr().out
assert "turns=8 · tools=7 · no_tool" in out
60 changes: 60 additions & 0 deletions apodex/tests/test_native.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,66 @@ def test_native_strategy_is_explicitly_not_os_isolated() -> None:
assert "not an OS sandbox" in strategy.describe()


def test_nonroot_native_current_sandbox_skips_tool_user_warning(
tmp_path, monkeypatch, caplog,
) -> None:
from plugins.tools import _sandbox as tool_sandbox

monkeypatch.setenv("APODEX_IN_NATIVE", "1")
monkeypatch.delenv("FRONTIER_AGENT_REQUIRE_TOOL_USER", raising=False)
monkeypatch.setattr(tool_sandbox.os, "geteuid", lambda: 1000)
monkeypatch.setattr(
tool_sandbox, "container_uses_inner_bwrap", lambda: False,
)

def unexpected_identity_lookup():
raise AssertionError(
"ordinary non-root native mode must not request a tool-user identity"
)

monkeypatch.setattr(
tool_sandbox, "tool_identity", unexpected_identity_lookup,
)

current = tool_sandbox.CurrentSandbox(tmp_path)

assert current.commands._identity is None
assert "Tool-user isolation inactive" not in caplog.text
assert "own uid" not in caplog.text


def test_strict_nonroot_native_still_requires_tool_identity(
tmp_path, monkeypatch,
) -> None:
from plugins.tools import _sandbox as tool_sandbox

monkeypatch.setenv("APODEX_IN_NATIVE", "1")
monkeypatch.setenv("FRONTIER_AGENT_REQUIRE_TOOL_USER", "1")
monkeypatch.setattr(tool_sandbox.os, "geteuid", lambda: 1000)
monkeypatch.setattr(
tool_sandbox, "container_uses_inner_bwrap", lambda: False,
)

calls = []

def required_identity():
calls.append(True)
raise tool_sandbox.SandboxUnavailableError(
"strict tool-user requirement exercised"
)

monkeypatch.setattr(tool_sandbox, "tool_identity", required_identity)

try:
tool_sandbox.CurrentSandbox(tmp_path)
except tool_sandbox.SandboxUnavailableError as exc:
assert "strict tool-user requirement exercised" in str(exc)
else:
raise AssertionError("strict native mode unexpectedly bypassed tool_identity")

assert calls == [True]


def test_native_runtime_resolves_canonical_mount_aliases(
tmp_path, monkeypatch,
) -> None:
Expand Down
35 changes: 28 additions & 7 deletions plugins/tools/_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -1428,6 +1428,23 @@ def _check_ulimit_below_cgroup(mem_mb: int) -> None:
)


def _native_same_uid_is_expected() -> bool:
"""True when local native execution intentionally uses the harness uid.

Native mode is explicitly not an OS sandbox: a normal non-root invocation
runs approved model commands with the current user's permissions. Do not
route that expected case through the container/service tool-user warning
path. Root-native and explicitly strict runs still attempt the dedicated
tool identity.
"""
return (
os.environ.get("APODEX_IN_NATIVE", "").strip() == "1"
and os.name == "posix"
and os.geteuid() != 0
and not _require_tool_user()
)


class _CurrentCommands:
"""Command executor for an existing checkout in the current process.

Expand All @@ -1438,8 +1455,10 @@ class _CurrentCommands:

def __init__(self, workdir: str, *, private_tmp: bool = False) -> None:
self._workdir = workdir
self._identity = tool_identity()
native = os.environ.get("APODEX_IN_NATIVE", "").strip() == "1"
self._identity = (
None if _native_same_uid_is_expected() else tool_identity()
)
self._runtime_home = (
os.environ.get("HOME", "").strip() or workdir
if native else workdir
Expand Down Expand Up @@ -2023,13 +2042,15 @@ def __init__(self, workdir: str | Path, *, private_tmp: bool = False) -> None:
self.commands = self._inner.commands
self.files = self._inner.files
else:
identity = tool_identity()
same_uid_expected = _native_same_uid_is_expected()
identity = None if same_uid_expected else tool_identity()
if identity is None:
logger.warning(
"CurrentSandbox running model commands with the harness's "
"own uid: the child environment is scrubbed, but "
"/proc/<harness-pid>/environ remains readable"
)
if not same_uid_expected:
logger.warning(
"CurrentSandbox running model commands with the harness's "
"own uid: the child environment is scrubbed, but "
"/proc/<harness-pid>/environ remains readable"
)
else:
_, outputs_dir, inputs_dir = resolve_mount_dirs()
_prepare_tool_writable(self._workdir, outputs_dir)
Expand Down
4 changes: 4 additions & 0 deletions workflows/stateful_react_agent/nodes/main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,10 @@ async def react_agent_node(state: dict[str, Any], ctx: NodeContext) -> dict[str,
steps=result.metadata.get("react_steps", []),
),
"react_steps": result.metadata.get("react_steps", []),
# Authoritative loop telemetry. ``react_steps`` counts tool results,
# not LLM turns, so it cannot substitute for either counter.
"turns_used": result.turns_used,
"tool_calls_count": result.tool_calls_count,
"language": answer_language,
# Keep the user-facing answer non-empty while preserving the old
# machine-readable infra/eval failure signal out of band.
Expand Down
Loading