419 lines
16 KiB
Python
419 lines
16 KiB
Python
"""Unit tests for opensquilla.contrib.codetask.adapter (subprocess mocked)."""
|
|
|
|
import subprocess as sp
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from opensquilla.contrib.codetask import adapter
|
|
from opensquilla.contrib.codetask.adapter import LocalAdapter, _agent_command
|
|
|
|
|
|
class FakePopen:
|
|
"""Minimal Popen stand-in for the adapter's communicate()-based flow."""
|
|
|
|
def __init__(self, cmd, stdout="", returncode=0, stderr="", timeout=False, **kwargs):
|
|
self.args = cmd
|
|
self.pid = 4242
|
|
self._stdout = stdout
|
|
self._stderr = stderr
|
|
self.returncode = returncode
|
|
self._timeout = timeout
|
|
self._calls = 0
|
|
self.killed = False
|
|
|
|
def communicate(self, timeout=None):
|
|
self._calls += 1
|
|
# Time out only on the first call (the bounded wait); the post-kill
|
|
# drain call returns normally.
|
|
if self._timeout and self._calls == 1:
|
|
raise sp.TimeoutExpired(self.args, timeout or 1)
|
|
return self._stdout, self._stderr
|
|
|
|
def kill(self):
|
|
self.killed = True
|
|
|
|
|
|
def _install_popen(monkeypatch, captured, **popen_kw):
|
|
def fake_popen(cmd, **kwargs):
|
|
captured["cmd"] = cmd
|
|
captured["cwd"] = kwargs.get("cwd")
|
|
captured["new_session"] = kwargs.get("start_new_session")
|
|
captured["env"] = kwargs.get("env")
|
|
captured["code"] = cmd[2]
|
|
return FakePopen(cmd, **popen_kw)
|
|
|
|
monkeypatch.setattr(adapter.subprocess, "Popen", fake_popen)
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="code-task Windows support is WIP")
|
|
def test_argv_has_host_containment_flags(monkeypatch, tmp_path):
|
|
captured = {}
|
|
_install_popen(monkeypatch, captured, stdout='{"status": "ok", "text": "done", "usage": {}}')
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
out = LocalAdapter(model="m", timeout=10).run(
|
|
"fix it", repo=repo, scratch_dir=tmp_path / "s", artifact_dir=tmp_path / "art"
|
|
)
|
|
assert out.success is True
|
|
code = captured["code"]
|
|
for flag in (
|
|
"--workspace",
|
|
"--workspace-strict",
|
|
"--workspace-lockdown",
|
|
"--scratch-dir",
|
|
"--stateless",
|
|
"--permissions",
|
|
):
|
|
assert flag in code
|
|
assert captured["cwd"] == str(repo)
|
|
# Descendant-killable process group (codex review #6).
|
|
assert captured["new_session"] is True
|
|
|
|
|
|
def test_api_key_never_in_argv(monkeypatch, tmp_path):
|
|
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-secret-xyz")
|
|
captured = {}
|
|
_install_popen(monkeypatch, captured, stdout='{"status": "ok", "text": "done"}')
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
LocalAdapter().run("x", repo=repo, scratch_dir=tmp_path / "s", artifact_dir=tmp_path / "a")
|
|
assert all("sk-or-secret-xyz" not in part for part in captured["cmd"])
|
|
|
|
|
|
def test_agent_command_uses_packaged_cli_directly_for_gateway_executable():
|
|
cmd = _agent_command(
|
|
"/Applications/OpenSquilla.app/Contents/Resources/runtime/gateway/opensquilla-gateway/opensquilla-gateway",
|
|
["opensquilla", "agent", "--message", "hello"],
|
|
"print('unused')",
|
|
)
|
|
|
|
assert cmd == [
|
|
"/Applications/OpenSquilla.app/Contents/Resources/runtime/gateway/opensquilla-gateway/opensquilla-gateway",
|
|
"agent",
|
|
"--message",
|
|
"hello",
|
|
]
|
|
assert "-c" not in cmd
|
|
|
|
|
|
def test_timeout_kills_group_and_reports_timeout(monkeypatch, tmp_path):
|
|
captured = {}
|
|
_install_popen(monkeypatch, captured, timeout=True)
|
|
killed = {"group": False}
|
|
monkeypatch.setattr(
|
|
adapter, "_kill_process_group", lambda proc: killed.__setitem__("group", True)
|
|
)
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
out = LocalAdapter(timeout=1).run(
|
|
"x", repo=repo, scratch_dir=tmp_path / "s", artifact_dir=tmp_path / "a"
|
|
)
|
|
assert out.timeout is True
|
|
assert out.finish_reason == "timeout"
|
|
assert killed["group"] is True
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="process-group signal differs on Windows")
|
|
def test_run_streams_logs_and_touches_observability_files(monkeypatch, tmp_path):
|
|
script = (
|
|
"import json, sys, time\n"
|
|
"print('first line')\n"
|
|
"sys.stdout.flush()\n"
|
|
"time.sleep(0.1)\n"
|
|
"print(json.dumps({'status': 'ok', 'text': 'done', 'usage': {'total_tokens': 7}}))\n"
|
|
"sys.stdout.flush()\n"
|
|
)
|
|
monkeypatch.setattr(adapter, "agent_python", lambda: sys.executable)
|
|
monkeypatch.setattr(
|
|
adapter, "_agent_command", lambda executable, argv, py_code: [sys.executable, "-c", script]
|
|
)
|
|
monkeypatch.setattr(adapter, "POLL_INTERVAL_SECONDS", 0.05)
|
|
statuses = []
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
|
|
out = LocalAdapter(timeout=5).run(
|
|
"secret prompt",
|
|
repo=repo,
|
|
scratch_dir=tmp_path / "s",
|
|
artifact_dir=tmp_path / "a",
|
|
status_callback=statuses.append,
|
|
quiet_timeout=2,
|
|
)
|
|
|
|
assert out.success is True
|
|
assert out.usage["total_tokens"] == 7
|
|
assert "first line" in (tmp_path / "a" / "agent_stdout.log").read_text("utf-8")
|
|
assert (tmp_path / "a" / "agent_stderr.log").is_file()
|
|
assert (tmp_path / "a" / "transcript.jsonl").is_file()
|
|
assert (tmp_path / "a" / "usage.json").is_file()
|
|
assert statuses
|
|
assert statuses[0]["log_paths"]["stdout"].endswith("agent_stdout.log")
|
|
assert "secret prompt" not in statuses[0]["current_command"]
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="process-group signal differs on Windows")
|
|
def test_run_marks_silent_agent_as_stalled(monkeypatch, tmp_path):
|
|
script = "import time\ntime.sleep(30)\n"
|
|
monkeypatch.setattr(adapter, "agent_python", lambda: sys.executable)
|
|
monkeypatch.setattr(
|
|
adapter, "_agent_command", lambda executable, argv, py_code: [sys.executable, "-c", script]
|
|
)
|
|
monkeypatch.setattr(adapter, "POLL_INTERVAL_SECONDS", 0.05)
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
|
|
out = LocalAdapter(timeout=10).run(
|
|
"x",
|
|
repo=repo,
|
|
scratch_dir=tmp_path / "s",
|
|
artifact_dir=tmp_path / "a",
|
|
quiet_timeout=1,
|
|
)
|
|
|
|
assert out.success is False
|
|
assert out.finish_reason == "stalled"
|
|
assert out.error and "no stdout/stderr/transcript/usage updates" in out.error
|
|
|
|
|
|
def test_run_clears_stale_scratch_manifest(monkeypatch, tmp_path):
|
|
# A leftover verification.json from a prior run reusing this run_id must
|
|
# be wiped before the agent runs, so the runner cannot read a stale
|
|
# manifest (codex review).
|
|
captured = {}
|
|
_install_popen(monkeypatch, captured, stdout='{"status": "ok", "text": "done"}')
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
scratch = tmp_path / "s"
|
|
scratch.mkdir()
|
|
stale = scratch / "verification.json"
|
|
stale.write_text('{"testable": true, "stale": true}')
|
|
|
|
LocalAdapter().run("x", repo=repo, scratch_dir=scratch, artifact_dir=tmp_path / "a")
|
|
|
|
# The stale manifest is gone (scratch was recreated clean).
|
|
assert not stale.exists()
|
|
assert scratch.is_dir()
|
|
|
|
|
|
def _isolate_operator_config(monkeypatch, tmp_path):
|
|
"""Point operator-config discovery at a missing file so tests exercise the
|
|
pure template regardless of the developer's real config/env."""
|
|
monkeypatch.setenv(
|
|
"OPENSQUILLA_GATEWAY_CONFIG_PATH", str(tmp_path / "no-operator-config.toml")
|
|
)
|
|
monkeypatch.delenv("OPENSQUILLA_CODETASK_AGENT_CONFIG", raising=False)
|
|
|
|
|
|
def test_run_points_agent_at_codetask_config(monkeypatch, tmp_path):
|
|
# The agent subprocess must load code-task's own config (deny list etc.)
|
|
# via OPENSQUILLA_GATEWAY_CONFIG_PATH, while still inheriting the parent env.
|
|
from opensquilla.contrib.codetask.config import agent_config_path
|
|
|
|
_isolate_operator_config(monkeypatch, tmp_path)
|
|
captured = {}
|
|
_install_popen(monkeypatch, captured, stdout='{"status": "ok", "text": "done"}')
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
LocalAdapter().run(
|
|
"x", repo=repo, scratch_dir=tmp_path / "s", artifact_dir=tmp_path / "a"
|
|
)
|
|
env = captured["env"]
|
|
assert env is not None
|
|
# The agent now loads a PER-RUN config (derived from code-task's base config)
|
|
# so its tool-result store is isolated to this run instead of the shared
|
|
# global media root -- avoids the quadratic global-store rescan / spin.
|
|
per_run_cfg = tmp_path / "a" / "agent-config.toml"
|
|
assert env["OPENSQUILLA_GATEWAY_CONFIG_PATH"] == str(per_run_cfg)
|
|
import tomllib
|
|
|
|
cfg_text = per_run_cfg.read_text(encoding="utf-8")
|
|
parsed = tomllib.loads(cfg_text)
|
|
base = tomllib.loads(agent_config_path().read_text(encoding="utf-8"))
|
|
# every base section survives the merge (deny list + policy preserved)...
|
|
for section in base:
|
|
assert section in parsed, section
|
|
# ...and media_root is pinned under THIS run's scratch (absolute).
|
|
assert parsed["attachments"]["media_root"] == str(
|
|
(tmp_path / "s").resolve() / "media"
|
|
)
|
|
from opensquilla.gateway.config import GatewayConfig
|
|
from opensquilla.paths import media_root_from_config
|
|
|
|
conf = GatewayConfig.load(str(per_run_cfg))
|
|
assert media_root_from_config(conf) == (tmp_path / "s").resolve() / "media"
|
|
assert "PATH" in env # inherits parent env (provider keys pass through)
|
|
|
|
|
|
def test_per_run_config_merges_existing_attachments(monkeypatch, tmp_path):
|
|
"""If the base agent config ever gains an [attachments] table, the per-run
|
|
config must OVERRIDE media_root (merge), not append a duplicate table that
|
|
breaks tomllib parsing."""
|
|
import tomllib
|
|
|
|
from opensquilla.contrib.codetask import adapter as adapter_mod
|
|
from opensquilla.contrib.codetask import agent_config as agent_config_mod
|
|
|
|
_isolate_operator_config(monkeypatch, tmp_path)
|
|
base = tmp_path / "base.toml"
|
|
base.write_text(
|
|
'[attachments]\nmedia_root = "/old/global"\npersist_transcripts = true\n'
|
|
"[tools]\ndeny = [\"x\"]\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setattr(agent_config_mod, "agent_config_path", lambda: base)
|
|
|
|
captured = {}
|
|
_install_popen(monkeypatch, captured, stdout='{"status": "ok", "text": "done"}')
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
adapter_mod.LocalAdapter().run(
|
|
"x", repo=repo, scratch_dir=tmp_path / "s", artifact_dir=tmp_path / "a"
|
|
)
|
|
cfg = tomllib.loads((tmp_path / "a" / "agent-config.toml").read_text("utf-8"))
|
|
# overridden, not duplicated; sibling key + other sections preserved
|
|
assert cfg["attachments"]["media_root"] == str((tmp_path / "s").resolve() / "media")
|
|
assert cfg["attachments"]["persist_transcripts"] is True
|
|
assert cfg["tools"]["deny"] == ["x"]
|
|
|
|
|
|
def test_per_run_config_inherits_operator_provider(monkeypatch, tmp_path):
|
|
"""The operator's provider stack replaces the template's; credentials are
|
|
kept out of the written file and travel via the child env instead
|
|
(issue #541)."""
|
|
import tomllib
|
|
|
|
operator_cfg = tmp_path / "operator.toml"
|
|
operator_cfg.write_text(
|
|
"[llm]\n"
|
|
'provider = "deepseek"\n'
|
|
'model = "deepseek-chat"\n'
|
|
'api_key = "sk-user-typed-secret"\n'
|
|
"[squilla_router]\n"
|
|
"enabled = true\n"
|
|
"[llm_profiles.moonshot]\n"
|
|
'api_key = "sk-profile-secret"\n'
|
|
"[llm_ensemble]\n"
|
|
"enabled = true\n",
|
|
encoding="utf-8",
|
|
)
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(operator_cfg))
|
|
monkeypatch.delenv("OPENSQUILLA_CODETASK_AGENT_CONFIG", raising=False)
|
|
|
|
captured = {}
|
|
_install_popen(monkeypatch, captured, stdout='{"status": "ok", "text": "done"}')
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
LocalAdapter().run(
|
|
"x", repo=repo, scratch_dir=tmp_path / "s", artifact_dir=tmp_path / "a"
|
|
)
|
|
|
|
written = (tmp_path / "a" / "agent-config.toml").read_text(encoding="utf-8")
|
|
parsed = tomllib.loads(written)
|
|
# Operator provider stack carried in...
|
|
assert parsed["llm"]["provider"] == "deepseek"
|
|
assert parsed["llm"]["model"] == "deepseek-chat"
|
|
assert parsed["squilla_router"]["enabled"] is True
|
|
# ...primary credential stripped from the on-disk file (snapshotted per
|
|
# attempt) and transported via the child env instead...
|
|
assert "api_key" not in parsed["llm"]
|
|
assert "sk-user-typed-secret" not in written
|
|
assert captured["env"]["OPENSQUILLA_LLM_API_KEY"] == "sk-user-typed-secret"
|
|
# ...profile keys stay in the 0600 file (no env transport channel exists)...
|
|
assert parsed["llm_profiles"]["moonshot"]["api_key"] == "sk-profile-secret"
|
|
# ...[llm_ensemble] deliberately not carried (env-key auto-opt-in would
|
|
# re-pin the subagent to a provider the operator moved away from)...
|
|
assert "llm_ensemble" not in parsed
|
|
# ...and the template's run policy stays authoritative.
|
|
for section in ("tools", "sandbox", "meta_skill", "memory"):
|
|
assert section in parsed, section
|
|
assert parsed["meta_skill"]["enabled"] is False
|
|
|
|
|
|
def test_per_run_config_without_operator_config_keeps_default_behavior(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""Upgrade parity: with no operator config anywhere, the subagent resolves
|
|
the same built-in default provider as before the inheritance change."""
|
|
import tomllib
|
|
|
|
_isolate_operator_config(monkeypatch, tmp_path)
|
|
monkeypatch.delenv("OPENSQUILLA_LLM_PROVIDER", raising=False)
|
|
monkeypatch.delenv("OPENSQUILLA_LLM_API_KEY", raising=False)
|
|
captured = {}
|
|
_install_popen(monkeypatch, captured, stdout='{"status": "ok", "text": "done"}')
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
LocalAdapter().run(
|
|
"x", repo=repo, scratch_dir=tmp_path / "s", artifact_dir=tmp_path / "a"
|
|
)
|
|
per_run_cfg = tmp_path / "a" / "agent-config.toml"
|
|
parsed = tomllib.loads(per_run_cfg.read_text(encoding="utf-8"))
|
|
# No pinned [llm]: provider resolution falls to defaults + env, exactly
|
|
# like the operator's own gateway with no config file.
|
|
assert "llm" not in parsed
|
|
from opensquilla.gateway.config import GatewayConfig
|
|
|
|
# Whatever a bare gateway would default to (don't hard-code the vendor —
|
|
# the built-in default provider changes over time), the subagent must
|
|
# resolve the SAME thing.
|
|
bare_default = GatewayConfig.load(str(tmp_path / "no-such-config.toml")).llm.provider
|
|
conf = GatewayConfig.load(str(per_run_cfg))
|
|
assert conf.llm.provider == bare_default
|
|
# No key to transport, so no injection either.
|
|
assert "OPENSQUILLA_LLM_API_KEY" not in captured["env"]
|
|
|
|
|
|
def test_agent_config_override_env_is_fully_authoritative(monkeypatch, tmp_path):
|
|
"""OPENSQUILLA_CODETASK_AGENT_CONFIG (the documented #541 escape hatch)
|
|
disables provider inheritance: the custom file is used as-is, apart from
|
|
the per-run media_root injection."""
|
|
import tomllib
|
|
|
|
override_cfg = tmp_path / "override.toml"
|
|
override_cfg.write_text(
|
|
'[llm]\nprovider = "deepseek"\nmodel = "deepseek-chat"\napi_key = "sk-in-override"\n',
|
|
encoding="utf-8",
|
|
)
|
|
operator_cfg = tmp_path / "operator.toml"
|
|
operator_cfg.write_text('[llm]\nprovider = "moonshot"\nmodel = "kimi-k2.6"\n', "utf-8")
|
|
monkeypatch.setenv("OPENSQUILLA_CODETASK_AGENT_CONFIG", str(override_cfg))
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(operator_cfg))
|
|
monkeypatch.delenv("OPENSQUILLA_LLM_API_KEY", raising=False)
|
|
|
|
captured = {}
|
|
_install_popen(monkeypatch, captured, stdout='{"status": "ok", "text": "done"}')
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
LocalAdapter().run(
|
|
"x", repo=repo, scratch_dir=tmp_path / "s", artifact_dir=tmp_path / "a"
|
|
)
|
|
parsed = tomllib.loads((tmp_path / "a" / "agent-config.toml").read_text("utf-8"))
|
|
assert parsed["llm"]["provider"] == "deepseek" # override wins, no merge
|
|
assert parsed["llm"]["api_key"] == "sk-in-override" # untouched (full authority)
|
|
assert parsed["attachments"]["media_root"] == str((tmp_path / "s").resolve() / "media")
|
|
assert "OPENSQUILLA_LLM_API_KEY" not in captured["env"] # no inheritance, no injection
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX file modes")
|
|
def test_per_run_config_is_owner_only(monkeypatch, tmp_path):
|
|
# The per-run file can carry [llm_profiles] credentials.
|
|
import stat
|
|
|
|
operator_cfg = tmp_path / "operator.toml"
|
|
operator_cfg.write_text(
|
|
'[llm]\nprovider = "deepseek"\nmodel = "deepseek-chat"\n', encoding="utf-8"
|
|
)
|
|
monkeypatch.setenv("OPENSQUILLA_GATEWAY_CONFIG_PATH", str(operator_cfg))
|
|
monkeypatch.delenv("OPENSQUILLA_CODETASK_AGENT_CONFIG", raising=False)
|
|
captured = {}
|
|
_install_popen(monkeypatch, captured, stdout='{"status": "ok", "text": "done"}')
|
|
repo = tmp_path / "repo"
|
|
repo.mkdir()
|
|
LocalAdapter().run(
|
|
"x", repo=repo, scratch_dir=tmp_path / "s", artifact_dir=tmp_path / "a"
|
|
)
|
|
mode = stat.S_IMODE((tmp_path / "a" / "agent-config.toml").stat().st_mode)
|
|
assert mode == 0o600
|