Skip to content

Commit 1d9acba

Browse files
authored
Merge pull request #49 from ApodexAI/codex/pr-36-ci-fix
Fix native workflow behavior and isolate path localization tests
2 parents 740fb77 + 5a84d62 commit 1d9acba

6 files changed

Lines changed: 212 additions & 12 deletions

File tree

‎apodex/agent_tools.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,21 @@ def localize_path_args(name: str, args: dict, cwd: str) -> dict | None:
284284
except Exception:
285285
continue
286286
if not (rel == ".." or rel.startswith(".." + os.sep)):
287+
# Native workflow mode deliberately separates the user's project
288+
# (cwd) from its run-private execution workspace. The workflow
289+
# read_file resolves relative paths in that private workspace, so
290+
# converting a correct absolute project path to "README.md" would
291+
# make it read the wrong filesystem location.
292+
runtime_workspace = os.environ.get(
293+
"FRONTIER_AGENT_WORKSPACE_DIR", ""
294+
).strip()
295+
if name == "read_file" and runtime_workspace:
296+
try:
297+
workspace_real = os.path.realpath(runtime_workspace)
298+
except Exception:
299+
workspace_real = ""
300+
if workspace_real and workspace_real != cwd_real:
301+
return None
287302
new = dict(args)
288303
new[key] = rel or "."
289304
return new

‎apodex/task_runner.py‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ def _is_complete_run(
9393
# phase hit its soft deadline. Its explicit complete status is authoritative.
9494
if answer_status == "complete" and answer_source == "reporter_llm":
9595
return True
96+
# Main-agent workflows may finish with a plain-text assistant turn, which
97+
# the loop records as ``no_tool``. Once the workflow has explicitly marked
98+
# that agent-produced answer complete, treat that terminal as successful.
99+
# Keep this narrower than accepting workflow ``no_tool`` in general: an
100+
# exhausted no-tool nudge budget can also use the same stop reason.
101+
if (
102+
stopped_by == _NO_TOOL_STOP
103+
and answer_status == "complete"
104+
and answer_source == "agent"
105+
):
106+
return True
96107
if no_tool_is_complete and stopped_by == _NO_TOOL_STOP:
97108
return True
98109
return stopped_by in _COMPLETE_TOP_LEVEL_STOPS
@@ -565,18 +576,28 @@ async def _run_native_workflow(self, task: str, profile: Any) -> None:
565576
answer_status=str(state.get("answer_status") or ""),
566577
answer_source=str(state.get("final_answer_source") or ""),
567578
)
579+
turns_used = (
580+
int(state.get("turns_used") or 0)
581+
if "turns_used" in state
582+
else len(state.get("react_steps") or [])
583+
)
584+
tool_calls_count = (
585+
int(state.get("tool_calls_count") or 0)
586+
if "tool_calls_count" in state
587+
else 0
588+
)
568589
if complete:
569590
self.r.final(
570591
final,
571-
turns=len(state.get("react_steps") or []),
572-
tool_calls=0,
592+
turns=turns_used,
593+
tool_calls=tool_calls_count,
573594
stopped_by=stopped_by,
574595
)
575596
else:
576597
self._show_incomplete_run(
577598
final,
578-
turns=len(state.get("react_steps") or []),
579-
tool_calls=0,
599+
turns=turns_used,
600+
tool_calls=tool_calls_count,
580601
stopped_by=stopped_by,
581602
)
582603
await self._render_changed_files()

‎apodex/tests/test_features.py‎

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,14 +276,47 @@ def test_gitignore_path_anchored_not_overpruned(tmp_path):
276276

277277

278278
# ── path localization (abs→rel avoids the ~50s sandbox slow path) ─────────
279-
def test_localize_absolute_path_inside_cwd(tmp_path):
279+
@pytest.mark.parametrize("workspace_mode", ["unset", "same", "symlink"])
280+
def test_localize_absolute_path_inside_cwd(tmp_path, monkeypatch, workspace_mode):
280281
cwd = str(tmp_path)
282+
# Localization is valid only when the runtime resolves paths in cwd.
283+
# Do not inherit a workspace configured by another test or the caller.
284+
monkeypatch.delenv("FRONTIER_AGENT_INPUTS_DIR", raising=False)
285+
monkeypatch.delenv("FRONTIER_AGENT_WORKSPACE_DIR", raising=False)
286+
if workspace_mode == "same":
287+
monkeypatch.setenv("FRONTIER_AGENT_WORKSPACE_DIR", cwd)
288+
elif workspace_mode == "symlink":
289+
workspace = tmp_path / "workspace-link"
290+
workspace.symlink_to(tmp_path, target_is_directory=True)
291+
monkeypatch.setenv("FRONTIER_AGENT_WORKSPACE_DIR", str(workspace))
281292
(tmp_path / "sub").mkdir()
282293
abspath = str(tmp_path / "sub" / "f.py")
283294
out = localize_path_args("read_file", {"path": abspath}, cwd)
284295
assert out is not None and out["path"] == "sub/f.py"
285296

286297

298+
def test_read_file_keeps_project_absolute_path_with_split_runtime_workspace(
299+
tmp_path, monkeypatch,
300+
):
301+
project = tmp_path / "project"
302+
runtime_workspace = tmp_path / "private-workspace"
303+
project.mkdir()
304+
runtime_workspace.mkdir()
305+
target = project / "README.md"
306+
target.write_text("# project\n")
307+
308+
monkeypatch.delenv("FRONTIER_AGENT_INPUTS_DIR", raising=False)
309+
monkeypatch.setenv(
310+
"FRONTIER_AGENT_WORKSPACE_DIR", str(runtime_workspace),
311+
)
312+
313+
out = localize_path_args(
314+
"read_file", {"path": str(target)}, str(project),
315+
)
316+
317+
assert out is None
318+
319+
287320
def test_localize_preserves_absolute_task_input_path(tmp_path, monkeypatch):
288321
inputs = tmp_path / ".apodex" / "inputs" / "run"
289322
inputs.mkdir(parents=True)
@@ -931,6 +964,8 @@ def test_native_workflow_no_tool_stop_is_not_reported_as_delivery(
931964
assert "no_tool" in out
932965
assert "partial output was not saved as a final report" in out
933966
assert "Final report" not in out
967+
assert "turns=12" in out
968+
assert "tools=0" in out
934969

935970

936971
def test_generic_loop_no_tool_stop_is_a_normal_finish():
@@ -942,6 +977,33 @@ def test_generic_loop_no_tool_stop_is_a_normal_finish():
942977
assert _is_complete_run("max_turns", no_tool_is_complete=True) is False
943978

944979

980+
def test_workflow_complete_agent_no_tool_is_a_normal_finish():
981+
"""A workflow-certified agent answer may terminate via a tool-free turn."""
982+
from apodex.task_runner import _is_complete_run
983+
984+
assert _is_complete_run(
985+
"no_tool",
986+
answer_status="complete",
987+
answer_source="agent",
988+
) is True
989+
990+
# Do not broaden workflow no_tool into an unconditional success signal.
991+
assert _is_complete_run(
992+
"no_tool",
993+
answer_status="best_effort",
994+
answer_source="agent",
995+
) is False
996+
assert _is_complete_run(
997+
"no_tool",
998+
answer_source="agent",
999+
) is False
1000+
assert _is_complete_run(
1001+
"max_turns",
1002+
answer_status="complete",
1003+
answer_source="agent",
1004+
) is False
1005+
1006+
9451007
async def _drive_workflow(session, profile, follow_up):
9461008
"""Run one native workflow with ``run_task`` stubbed to record follow-ups."""
9471009
session.run_task = follow_up # type: ignore[method-assign]
@@ -1528,3 +1590,20 @@ def test_download_file_target_is_the_resolved_destination(monkeypatch, tmp_path)
15281590
assert named.startswith(str(tmp_path / "downloads" / "p.pdf"))
15291591
assert "/elsewhere/" not in named # the requested directory is ignored
15301592
assert "renamed" in named # collisions rename it
1593+
1594+
1595+
def test_native_workflow_uses_authoritative_loop_telemetry(
1596+
tmp_path, monkeypatch, capsys,
1597+
):
1598+
_run_workflow_returning({
1599+
"final_answer": "done",
1600+
"answer_status": "complete",
1601+
"final_answer_source": "agent",
1602+
"stopped_by": "no_tool",
1603+
"react_steps": [{}] * 7,
1604+
"turns_used": 8,
1605+
"tool_calls_count": 7,
1606+
}, tmp_path, monkeypatch)
1607+
1608+
out = capsys.readouterr().out
1609+
assert "turns=8 · tools=7 · no_tool" in out

‎apodex/tests/test_native.py‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,66 @@ def test_native_strategy_is_explicitly_not_os_isolated() -> None:
133133
assert "not an OS sandbox" in strategy.describe()
134134

135135

136+
def test_nonroot_native_current_sandbox_skips_tool_user_warning(
137+
tmp_path, monkeypatch, caplog,
138+
) -> None:
139+
from plugins.tools import _sandbox as tool_sandbox
140+
141+
monkeypatch.setenv("APODEX_IN_NATIVE", "1")
142+
monkeypatch.delenv("FRONTIER_AGENT_REQUIRE_TOOL_USER", raising=False)
143+
monkeypatch.setattr(tool_sandbox.os, "geteuid", lambda: 1000)
144+
monkeypatch.setattr(
145+
tool_sandbox, "container_uses_inner_bwrap", lambda: False,
146+
)
147+
148+
def unexpected_identity_lookup():
149+
raise AssertionError(
150+
"ordinary non-root native mode must not request a tool-user identity"
151+
)
152+
153+
monkeypatch.setattr(
154+
tool_sandbox, "tool_identity", unexpected_identity_lookup,
155+
)
156+
157+
current = tool_sandbox.CurrentSandbox(tmp_path)
158+
159+
assert current.commands._identity is None
160+
assert "Tool-user isolation inactive" not in caplog.text
161+
assert "own uid" not in caplog.text
162+
163+
164+
def test_strict_nonroot_native_still_requires_tool_identity(
165+
tmp_path, monkeypatch,
166+
) -> None:
167+
from plugins.tools import _sandbox as tool_sandbox
168+
169+
monkeypatch.setenv("APODEX_IN_NATIVE", "1")
170+
monkeypatch.setenv("FRONTIER_AGENT_REQUIRE_TOOL_USER", "1")
171+
monkeypatch.setattr(tool_sandbox.os, "geteuid", lambda: 1000)
172+
monkeypatch.setattr(
173+
tool_sandbox, "container_uses_inner_bwrap", lambda: False,
174+
)
175+
176+
calls = []
177+
178+
def required_identity():
179+
calls.append(True)
180+
raise tool_sandbox.SandboxUnavailableError(
181+
"strict tool-user requirement exercised"
182+
)
183+
184+
monkeypatch.setattr(tool_sandbox, "tool_identity", required_identity)
185+
186+
try:
187+
tool_sandbox.CurrentSandbox(tmp_path)
188+
except tool_sandbox.SandboxUnavailableError as exc:
189+
assert "strict tool-user requirement exercised" in str(exc)
190+
else:
191+
raise AssertionError("strict native mode unexpectedly bypassed tool_identity")
192+
193+
assert calls == [True]
194+
195+
136196
def test_native_runtime_resolves_canonical_mount_aliases(
137197
tmp_path, monkeypatch,
138198
) -> None:

‎plugins/tools/_sandbox.py‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1428,6 +1428,23 @@ def _check_ulimit_below_cgroup(mem_mb: int) -> None:
14281428
)
14291429

14301430

1431+
def _native_same_uid_is_expected() -> bool:
1432+
"""True when local native execution intentionally uses the harness uid.
1433+
1434+
Native mode is explicitly not an OS sandbox: a normal non-root invocation
1435+
runs approved model commands with the current user's permissions. Do not
1436+
route that expected case through the container/service tool-user warning
1437+
path. Root-native and explicitly strict runs still attempt the dedicated
1438+
tool identity.
1439+
"""
1440+
return (
1441+
os.environ.get("APODEX_IN_NATIVE", "").strip() == "1"
1442+
and os.name == "posix"
1443+
and os.geteuid() != 0
1444+
and not _require_tool_user()
1445+
)
1446+
1447+
14311448
class _CurrentCommands:
14321449
"""Command executor for an existing checkout in the current process.
14331450
@@ -1438,8 +1455,10 @@ class _CurrentCommands:
14381455

14391456
def __init__(self, workdir: str, *, private_tmp: bool = False) -> None:
14401457
self._workdir = workdir
1441-
self._identity = tool_identity()
14421458
native = os.environ.get("APODEX_IN_NATIVE", "").strip() == "1"
1459+
self._identity = (
1460+
None if _native_same_uid_is_expected() else tool_identity()
1461+
)
14431462
self._runtime_home = (
14441463
os.environ.get("HOME", "").strip() or workdir
14451464
if native else workdir
@@ -2023,13 +2042,15 @@ def __init__(self, workdir: str | Path, *, private_tmp: bool = False) -> None:
20232042
self.commands = self._inner.commands
20242043
self.files = self._inner.files
20252044
else:
2026-
identity = tool_identity()
2045+
same_uid_expected = _native_same_uid_is_expected()
2046+
identity = None if same_uid_expected else tool_identity()
20272047
if identity is None:
2028-
logger.warning(
2029-
"CurrentSandbox running model commands with the harness's "
2030-
"own uid: the child environment is scrubbed, but "
2031-
"/proc/<harness-pid>/environ remains readable"
2032-
)
2048+
if not same_uid_expected:
2049+
logger.warning(
2050+
"CurrentSandbox running model commands with the harness's "
2051+
"own uid: the child environment is scrubbed, but "
2052+
"/proc/<harness-pid>/environ remains readable"
2053+
)
20332054
else:
20342055
_, outputs_dir, inputs_dir = resolve_mount_dirs()
20352056
_prepare_tool_writable(self._workdir, outputs_dir)

‎workflows/stateful_react_agent/nodes/main_agent.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1162,6 +1162,10 @@ async def react_agent_node(state: dict[str, Any], ctx: NodeContext) -> dict[str,
11621162
steps=result.metadata.get("react_steps", []),
11631163
),
11641164
"react_steps": result.metadata.get("react_steps", []),
1165+
# Authoritative loop telemetry. ``react_steps`` counts tool results,
1166+
# not LLM turns, so it cannot substitute for either counter.
1167+
"turns_used": result.turns_used,
1168+
"tool_calls_count": result.tool_calls_count,
11651169
"language": answer_language,
11661170
# Keep the user-facing answer non-empty while preserving the old
11671171
# machine-readable infra/eval failure signal out of band.

0 commit comments

Comments
 (0)