From c47f4a53bc1aacfd06bd3ea74eb2a7f581aeacc9 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 4 Oct 2026 14:44:08 -0700 Subject: [PATCH 1/2] Inside an OpenShell sandbox: say what a blocked request means, and let the proxy look names up With OPENSHELL_SANDBOX=1 the agent is pointed at OpenShell's policy skill when it exists, and at the openshell command when it does not. web_fetch hands a name to OpenShell's proxy; a literal internal address is still refused. --- coworker/agent.py | 17 ++++ coworker/sandbox/inside.py | 71 +++++++++++++++ coworker/web/guard.py | 9 ++ tests/test_openshell_inside.py | 160 +++++++++++++++++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 coworker/sandbox/inside.py create mode 100644 tests/test_openshell_inside.py diff --git a/coworker/agent.py b/coworker/agent.py index a68a8b10d..5da0637c6 100644 --- a/coworker/agent.py +++ b/coworker/agent.py @@ -48,6 +48,7 @@ from .tools.subagent import explorer_tools from .web import make_web_fetch_tool, make_web_search_tool from .workspace_trust import WorkspaceTrustStore +from .sandbox import inside as _openshell from .sandbox.selection import select as select_sandbox from .sandbox.workspace import open_workspace from .tools.todo import TodoList @@ -205,6 +206,10 @@ def _skill_dirs(workspace: Optional[Path]) -> list[Path]: dirs = [state_dir() / "skills"] if workspace is not None: dirs.append(workspace / ".coworker" / "skills") + # Inside an OpenShell sandbox its own skills join the menu (sandbox/inside.py). The + # folder may be missing, or filled in later, when the user turns proposals on. + if _openshell.inside_openshell(): + dirs.append(_openshell.SKILLS_DIR) return dirs @@ -310,6 +315,13 @@ def build_engine( # it (2026-09-14: the first trial spilled under the run's log folder and read_file # answered "path escapes the session's directories"). The workspace itself is never # written to, so a repository or task tree stays clean. + # Inside an OpenShell sandbox the agent may read OpenShell's skills folder: the policy + # skill there points to a longer file beside it. + if _openshell.inside_openshell() and root_list and not any( + _is_within(_openshell.SKILLS_DIR, r.path) for r in root_list + ): + root_list.append(RootDir(path=_openshell.SKILLS_DIR, writable=False, label="openshell-skills")) + if tool_result_spill_dir is not None: spill_dir: Optional[Path] = Path(tool_result_spill_dir).expanduser().resolve() else: @@ -657,6 +669,11 @@ def context_provider() -> str: text = sandbox_ctx() if text: parts.append(text) + # OpenWorker itself inside an OpenShell sandbox: what a blocked request looks like + # and what to do. Read each turn, since the policy skill can appear mid-session. + openshell_ctx = _openshell.context() + if openshell_ctx: + parts.append(openshell_ctx) # Live skill menu (SKILLS-SPEC §4.1): recomputed every turn like the roots list, so # a skill installed/enabled/disabled mid-session applies from the NEXT MESSAGE — # no new session, no lost context. diff --git a/coworker/sandbox/inside.py b/coworker/sandbox/inside.py new file mode 100644 index 000000000..0600e9da8 --- /dev/null +++ b/coworker/sandbox/inside.py @@ -0,0 +1,71 @@ +"""OpenWorker running INSIDE an OpenShell sandbox (`openshell sandbox create -- openworker run …`). + +Not a mode: OpenShell is the wall, and OpenWorker's own sandbox is off, as it is on any +computer that has not turned one on. OpenShell says it is there with `OPENSHELL_SANDBOX=1`, +and three things follow from that, all here: + +- the agent is told what a blocked request looks like and what to do about it; +- OpenShell's own skills folder is readable, so the agent can follow OpenShell's policy + skill as OpenShell prescribes (it exists only when the user turned proposals on); +- the web tools leave the name lookup to OpenShell's proxy (web/guard.py): inside the + sandbox no name resolves and nothing connects directly. + +Sites are added from outside the sandbox with OpenShell's own commands. OpenWorker does not +grant them, so `request_network_access` and the allowed-sites card are not part of this. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# Where OpenShell writes its agent-facing skills. The policy skill appears when the +# sandbox's `agent_policy_proposals_enabled` setting is on, and can appear mid-session. +SKILLS_DIR = Path("/etc/openshell/skills") +POLICY_SKILL = "openshell-policy-advisor" +_POLICY_SKILL_FILE = Path("policy-advisor") / "SKILL.md" + + +def inside_openshell() -> bool: + return os.environ.get("OPENSHELL_SANDBOX", "").strip() == "1" + + +def policy_skill_present() -> bool: + try: + return (SKILLS_DIR / _POLICY_SKILL_FILE).is_file() + except OSError: + return False + + +def proxied(scheme: str) -> bool: + """Whether a request of this scheme leaves through a proxy named in the environment + (the way OpenShell routes everything). The HTTP client reads the same variables.""" + names = ("HTTPS_PROXY", "https_proxy") if scheme == "https" else ("HTTP_PROXY", "http_proxy") + return any(os.environ.get(name, "").strip() for name in (*names, "ALL_PROXY", "all_proxy")) + + +def context() -> str: + """The lines the agent reads each turn. Empty outside OpenShell.""" + if not inside_openshell(): + return "" + lines = [ + "This session runs inside an OpenShell sandbox. Every network request, from your " + "commands and your web tools alike, goes through its proxy, which allows only what " + "its policy lists. A request the policy does not allow fails with a 403 from the " + "proxy (for example `CONNECT tunnel failed, response 403`) or says `policy_denied`. " + "That means the sandbox blocked it. Do not retry it unchanged, and do not look for " + "another route to the same place." + ] + if policy_skill_present(): + lines.append( + f"If the task needs a blocked request, load the skill `{POLICY_SKILL}` and follow " + "it: it explains how to ask for the access, which a person outside the sandbox " + "approves." + ) + else: + lines.append( + "If the task needs a blocked request, tell the user which host and port were " + "blocked. They can allow it from outside the sandbox with " + "`openshell policy update --add-endpoint HOST:PORT`." + ) + return " ".join(lines) diff --git a/coworker/web/guard.py b/coworker/web/guard.py index 5ceab0cba..84b931403 100644 --- a/coworker/web/guard.py +++ b/coworker/web/guard.py @@ -77,6 +77,15 @@ def _vet(url: str) -> tuple[Optional[str], Optional[str]]: reason = _blocked_reason(literal) return (f"refusing to fetch {host}: {reason}" if reason else None), None + # Inside an OpenShell sandbox no name resolves and nothing connects directly: every + # request goes to OpenShell's proxy, which looks the name up itself and holds it to the + # sandbox's policy. So the name is not vetted or pinned here; the client hands it to + # the proxy. (A literal address was still judged above.) + from ..sandbox.inside import inside_openshell, proxied + + if inside_openshell() and proxied(parts.scheme): + return None, None + try: infos = socket.getaddrinfo(host, parts.port or (443 if parts.scheme == "https" else 80), proto=socket.IPPROTO_TCP) diff --git a/tests/test_openshell_inside.py b/tests/test_openshell_inside.py new file mode 100644 index 000000000..396a2738d --- /dev/null +++ b/tests/test_openshell_inside.py @@ -0,0 +1,160 @@ +"""OpenWorker running inside an OpenShell sandbox (`OPENSHELL_SANDBOX=1`): the agent is +told what a blocked request looks like, OpenShell's own skills are readable, and the web +tools leave the name lookup to OpenShell's proxy. Outside OpenShell nothing changes.""" + +from __future__ import annotations + +import socket +from pathlib import Path + +import pytest + +from coworker.agent import build_engine +from coworker.agents.cowork import cowork_agent +from coworker.sandbox import inside +from coworker.web import guard + +PROXY_ENV = ("HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy") + + +class _StubProvider: + def complete(self, **_kw): # pragma: no cover - never called at build time + from coworker.providers import AssistantTurn + + return AssistantTurn() + + def capabilities(self, _model): # pragma: no cover + from coworker.providers.base import ModelCapabilities + + return ModelCapabilities() + + +@pytest.fixture +def outside(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OPENSHELL_SANDBOX", raising=False) + for name in PROXY_ENV: + monkeypatch.delenv(name, raising=False) + + +@pytest.fixture +def in_openshell(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: + """Inside a sandbox whose proposals are off: no skills folder yet.""" + for name in PROXY_ENV: + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("OPENSHELL_SANDBOX", "1") + monkeypatch.setenv("HTTPS_PROXY", "http://10.200.0.1:3128") + skills = tmp_path / "etc-openshell-skills" + monkeypatch.setattr(inside, "SKILLS_DIR", skills) + return skills + + +def _turn_on_proposals(skills: Path) -> None: + """What OpenShell writes when `agent_policy_proposals_enabled` is set.""" + (skills / "policy-advisor").mkdir(parents=True) + (skills / "policy-advisor" / "SKILL.md").write_text( + "---\nname: openshell-policy-advisor\n" + "description: Use when an OpenShell sandbox returns policy_denied.\n---\n\n" + "Read `/etc/openshell/skills/policy_advisor.md`.\n", + encoding="utf-8", + ) + (skills / "policy_advisor.md").write_text("# OpenShell Policy Advisor\n", encoding="utf-8") + + +def _engine(tmp_path: Path): + ws = tmp_path / "ws" + ws.mkdir() + return build_engine(agent=cowork_agent(), workspace=ws, provider=_StubProvider()) + + +# -- what the agent is told ------------------------------------------------------------- + + +def test_outside_openshell_nothing_is_said_or_added(outside, tmp_path: Path) -> None: + assert inside.context() == "" + engine = _engine(tmp_path) + assert "OpenShell" not in engine.context_provider() + assert [r.label for r in engine.roots if r.label == "openshell-skills"] == [] + + +def test_inside_the_agent_is_told_what_a_blocked_request_looks_like(in_openshell: Path, tmp_path: Path) -> None: + text = _engine(tmp_path).context_provider() + assert "runs inside an OpenShell sandbox" in text + assert "CONNECT tunnel failed, response 403" in text and "policy_denied" in text + # Proposals are off, so there is no skill to load: the person changes the policy. + assert "openshell policy update --add-endpoint HOST:PORT" in text + assert "openshell-policy-advisor" not in text + + +def test_with_proposals_on_the_agent_is_pointed_at_openshells_skill(in_openshell: Path, tmp_path: Path) -> None: + _turn_on_proposals(in_openshell) + engine = _engine(tmp_path) + text = engine.context_provider() + assert "load the skill `openshell-policy-advisor`" in text + assert "openshell policy update" not in text + # The skill is on the menu under OpenShell's own name and description… + assert "Use when an OpenShell sandbox returns policy_denied." in text + assert engine.skill_loader.get("openshell-policy-advisor") is not None + # …and its folder is one the agent may read, never write. + (root,) = [r for r in engine.roots if r.label == "openshell-skills"] + assert root.path == in_openshell.resolve() and root.writable is False + + +def test_proposals_turned_on_mid_session_are_picked_up_on_the_next_turn(in_openshell: Path, tmp_path: Path) -> None: + engine = _engine(tmp_path) + assert "openshell policy update" in engine.context_provider() + _turn_on_proposals(in_openshell) + text = engine.context_provider() + assert "load the skill `openshell-policy-advisor`" in text + assert "Use when an OpenShell sandbox returns policy_denied." in text + + +# -- web tools -------------------------------------------------------------------------- + + +def _no_lookup(*_a, **_k): + raise AssertionError("the name must not be looked up here") + + +def test_inside_a_name_goes_to_the_proxy_without_a_lookup(in_openshell, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(socket, "getaddrinfo", _no_lookup) + assert guard._vet("https://example.org/page") == (None, None) + + class Client: + def __init__(self) -> None: + self.asked: list[str] = [] + + def get(self, url, **kwargs): + self.asked.append(url) + assert not kwargs # no pinned address, no Host override: the name as written + return type("R", (), {"status_code": 200, "extensions": {}})() + + client = Client() + resp = guard.get_checked(client, "https://example.org/page") + assert client.asked == ["https://example.org/page"] + assert resp.extensions["logical_url"] == "https://example.org/page" + + +def test_inside_a_literal_internal_address_is_still_refused(in_openshell, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(socket, "getaddrinfo", _no_lookup) + for url in ("http://169.254.169.254/latest/meta-data/", "https://127.0.0.1:8443/", "https://10.0.0.5/"): + reason, pin = guard._vet(url) + assert reason and "refusing to fetch" in reason and pin is None + + +def test_inside_without_a_proxy_for_the_scheme_the_name_is_still_vetted(in_openshell, monkeypatch: pytest.MonkeyPatch) -> None: + # Only HTTPS_PROXY is set: a plain http request would connect directly, so it is checked. + looked_up: list[str] = [] + + def lookup(host, *_a, **_k): + looked_up.append(host) + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 80))] + + monkeypatch.setattr(socket, "getaddrinfo", lookup) + reason, _pin = guard._vet("http://rebind.example.org/") + assert looked_up == ["rebind.example.org"] and reason and "refusing to fetch" in reason + + +def test_outside_a_name_is_looked_up_and_pinned_as_before(outside, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.example:3128") # a proxy alone changes nothing + monkeypatch.setattr(socket, "getaddrinfo", lambda *_a, **_k: [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 443))]) + assert guard._vet("https://example.org/") == (None, "93.184.216.34") From 5c957d2185b18223183fb111d9182a91b6f61390 Mon Sep 17 00:00:00 2001 From: Rohit C Prasad Date: Sun, 4 Oct 2026 15:46:50 -0700 Subject: [PATCH 2/2] run: the retry line gives the cause, and a stop signal ends the wait between retries A failed model call prints what was underneath it, such as a proxy's 403. SIGTERM during the wait before a retry now stops the run and leaves its record (exit 130). --- coworker/headless/runner.py | 77 ++++++++++++++++++++++++++++++++--- docs/headless.md | 7 +++- tests/test_run_cli.py | 80 +++++++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 7 deletions(-) diff --git a/coworker/headless/runner.py b/coworker/headless/runner.py index 7db9ad22c..cc7fa2871 100644 --- a/coworker/headless/runner.py +++ b/coworker/headless/runner.py @@ -365,7 +365,12 @@ def complete(self, *, model, messages, tools=None, **settings): # A scripted provider failure: {"error": "Service unavailable", # "error_type": "APIError"} raises an exception of that name, so the # transient-error retry can be tested offline. + # "cause" / "cause_type" add the exception underneath, as a client library + # wraps a proxy or socket failure. exc_cls = type(str(t.get("error_type") or "APIError"), (Exception,), {}) + if t.get("cause"): + cause_cls = type(str(t.get("cause_type") or "OSError"), (Exception,), {}) + raise exc_cls(str(t["error"])) from cause_cls(str(t["cause"])) raise exc_cls(str(t["error"])) calls = [ ToolCall( @@ -484,6 +489,25 @@ def sanitize_key_env() -> list[str]: return cleaned +def root_cause_text(exc: BaseException) -> str: + """The exception at the bottom of a failed model call's chain, in a few words: what a + person needs to see beside "Connection error." Empty when there is nothing underneath.""" + cause = exc.__cause__ or exc.__context__ + last: Optional[BaseException] = None + depth = 0 + while cause is not None and depth < 8: + last = cause + cause = cause.__cause__ or cause.__context__ + depth += 1 + if last is None: + return "" + name = type(last).__name__ + detail = " ".join(str(last).split())[:200] + if name == "ProxyError": + return f"the proxy answered {detail}" if detail else "the proxy refused the connection" + return f"{name}: {detail}" if detail else name + + def recording_provider(inner, log_path: Path): """Wrap a ProviderClient so every model call leaves one line in model_calls.jsonl (stop reason, usage, ceiling, effort, serving host) and every exception is written to @@ -495,8 +519,14 @@ def recording_provider(inner, log_path: Path): class Recording(ProviderClient): def __init__(self) -> None: self.inner = inner + # The root cause of the last failed call, for the line the person sees. + self.last_cause = "" def _record(self, exc: BaseException, method: str) -> None: + try: + self.last_cause = redact(root_cause_text(exc)) + except Exception: # noqa: BLE001 + self.last_cause = "" try: lines = [f"=== {time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} {method} ==="] lines.append("".join(traceback.format_exception(exc))) @@ -928,12 +958,38 @@ def flush_provisional(status: str) -> None: import signal + # A stop signal (what `timeout`, a CI job or `docker stop` sends) ends the run: the + # engine is interrupted, and a wait between retries is cut short. Without the second + # part a run waiting out a provider error ignored the signal until a hard kill. + stop: dict[str, Any] = {"asked": False, "loop": None, "event": None} + def _on_term(signum, frame): # noqa: ARG001 + stop["asked"] = True try: engine.request_interrupt() + loop, event = stop["loop"], stop["event"] + if loop is not None and event is not None: + loop.call_soon_threadsafe(event.set) finally: flush_provisional("killed") + async def wait_or_stop(seconds: float) -> bool: + """Wait before a retry. True when a stop signal arrived instead.""" + if stop["asked"]: + return True + try: + await asyncio.wait_for(stop["event"].wait(), timeout=seconds) + except asyncio.TimeoutError: + return False + return True + + def error_with_cause(data: dict[str, Any]) -> str: + """`Connection error.` says nothing; the cause underneath does (a proxy's 403, a + refused socket). Shown on the retry line so nobody has to open the record.""" + text = f"{data.get('error_type')}: {str(data.get('error'))[:120]}" + cause = str(getattr(provider, "last_cause", "") or "") + return f"{text} Cause: {cause}" if cause and cause not in text else text + for sig in (signal.SIGTERM, getattr(signal, "SIGHUP", None)): if sig is not None: try: @@ -942,6 +998,7 @@ def _on_term(signum, frame): # noqa: ARG001 pass async def consume() -> None: + stop["loop"], stop["event"] = asyncio.get_running_loop(), asyncio.Event() delays = provider_retry_delays() retries = 0 message: Any = prompt @@ -978,20 +1035,21 @@ async def consume() -> None: "delay_seconds": delay, "error": last_data.get("error"), "error_type": last_data.get("error_type"), + "cause": str(getattr(provider, "last_cause", "") or ""), }, } events.append(rec) events_fh.write(json.dumps(rec, default=str) + "\n") events_fh.flush() print( - f"openworker run: provider error ({last_data.get('error_type')}: " - f"{str(last_data.get('error'))[:120]}); retry {retries}/{len(delays)} " - f"in {delay}s", + f"openworker run: provider error ({error_with_cause(last_data)}); " + f"retry {retries}/{len(delays)} in {delay}s", file=sys.stderr, flush=True, ) flush_provisional("in_progress") - await asyncio.sleep(delay) + if await wait_or_stop(delay): + break message = PROVIDER_RETRY_NUDGE continue # An empty reply ended the turn as "completed": same wait-and-nudge, same budget. @@ -1023,7 +1081,8 @@ async def consume() -> None: flush=True, ) flush_provisional("in_progress") - await asyncio.sleep(delay) + if await wait_or_stop(delay): + break message = EMPTY_REPLY_NUDGE continue break @@ -1073,6 +1132,8 @@ async def guarded() -> None: except Exception: # noqa: BLE001 pass + if stop["asked"] and outcome in ("unknown", "timeout"): + outcome = "interrupted" # a stop signal ended it, whatever the last event was # Outcome from the events when the loop ended on its own. if outcome == "unknown": outcome = "ended_without_turn_end" @@ -1122,7 +1183,11 @@ async def guarded() -> None: return EXIT_INTERRUPTED if outcome != "completed" and error_text: # Why it stopped, in a line: the full text is in the record. - print(f"openworker run: {redact(error_text.strip().splitlines()[-1])[:600]}", file=sys.stderr, flush=True) + why = redact(error_text.strip().splitlines()[-1])[:600] + cause = str(getattr(provider, "last_cause", "") or "") + if outcome == "model_error" and cause and cause not in why: + why = f"{why} Cause: {cause}" + print(f"openworker run: {why}", file=sys.stderr, flush=True) return EXIT_FINISHED if outcome == "completed" else EXIT_STOPPED diff --git a/docs/headless.md b/docs/headless.md index 2fafe9803..5ebddd1f2 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -156,7 +156,7 @@ record states it. | 1 | the run crashed before it could write a record | | 2 | the command line could not be run as given | | 3 | the run stopped early (time limit, model error, iteration limit, cut-off reply); the record is written | -| 130 | stopped with Ctrl-C; the record is written | +| 130 | stopped with Ctrl-C or a stop signal (what `timeout`, a CI job or `docker stop` sends); the record is written | ### The record @@ -176,3 +176,8 @@ The trajectory and the exit code are the stable contract. A transient provider failure or an empty reply does not end the run: the runner waits (30 s, 60 s, 120 s, 240 s, 300 s, 300 s) and re-enters the conversation with a nudge. Permanent errors (bad key, unknown model, context overflow) are not retried. + +Each retry prints a line with the error and its cause, for example +`provider error (APIConnectionError: Connection error. Cause: the proxy answered 403 +Forbidden); retry 1/6 in 30s`. A stop signal or Ctrl-C ends the run at once, also during +the wait between retries, and the record is still written. diff --git a/tests/test_run_cli.py b/tests/test_run_cli.py index 4cf2291e3..78a43cd3e 100644 --- a/tests/test_run_cli.py +++ b/tests/test_run_cli.py @@ -535,3 +535,83 @@ def test_with_auto_answer_a_folder_request_is_declined_by_rule(tmp_path: Path) - (folder,) = _session_folders(machine, ws) assert json.loads((folder / "summary.json").read_text(encoding="utf-8"))["answers"]["directory_requests_declined"] == 1 + +# -- a failing model call --------------------------------------------------------------- + + +def test_the_root_cause_is_the_bottom_of_the_chain() -> None: + from coworker.headless.runner import root_cause_text + + ProxyError = type("ProxyError", (Exception,), {}) + try: + try: + try: + raise ProxyError("403 Forbidden") + except Exception as low: + raise RuntimeError("httpx failed") from low + except Exception as mid: + raise ConnectionError("Connection error.") from mid + except Exception as top: + assert root_cause_text(top) == "the proxy answered 403 Forbidden" + + try: + try: + raise OSError("[Errno 111] Connection refused") + except Exception as low: + raise ConnectionError("Connection error.") from low + except Exception as top: + assert root_cause_text(top) == "OSError: [Errno 111] Connection refused" + + assert root_cause_text(ValueError("nothing underneath")) == "" + + +REFUSED_BY_A_PROXY = {"error": "Connection error.", "error_type": "APIConnectionError", "cause": "403 Forbidden", "cause_type": "ProxyError"} + + +def test_the_retry_line_says_why_the_model_call_failed(tmp_path: Path) -> None: + machine = tmp_path / "machine" + machine.mkdir() + cmd, ws = _command(tmp_path, [REFUSED_BY_A_PROXY, {"text": "Recovered."}], "--model", "anthropic/claude-sonnet-5") + env = {**_env(machine), "OPENWORKER_RUN_RETRY_DELAYS": "0"} + proc = subprocess.run(cmd, env=env, capture_output=True, text=True, timeout=300) + assert proc.returncode == 0, proc.stderr + assert "provider error (APIConnectionError: Connection error. Cause: the proxy answered 403 Forbidden); retry 1/1 in 0s" in proc.stderr + (folder,) = _session_folders(machine, ws) + events = [json.loads(line) for line in (folder / "events.jsonl").read_text(encoding="utf-8").splitlines()] + (retry,) = [e for e in events if e["type"] == "provider_retry"] + assert retry["data"]["cause"] == "the proxy answered 403 Forbidden" + + # When the retries run out, the closing line says why as well. + second = tmp_path / "second" + second.mkdir() + cmd, _ws = _command(second, [REFUSED_BY_A_PROXY, REFUSED_BY_A_PROXY], "--model", "anthropic/claude-sonnet-5") + proc = subprocess.run(cmd, env={**_env(machine), "OPENWORKER_RUN_RETRY_DELAYS": "0"}, capture_output=True, text=True, timeout=300) + assert proc.returncode == 3 + assert proc.stderr.rstrip().splitlines()[-1].endswith("Cause: the proxy answered 403 Forbidden") + + +@pytest.mark.skipif(sys.platform == "win32", reason="sends a stop signal") +def test_a_stop_signal_ends_the_run_during_the_wait_between_retries(tmp_path: Path) -> None: + import signal + + machine = tmp_path / "machine" + machine.mkdir() + cmd, ws = _command(tmp_path, [REFUSED_BY_A_PROXY, {"text": "never reached"}], "--model", "anthropic/claude-sonnet-5") + env = {**_env(machine), "OPENWORKER_RUN_RETRY_DELAYS": "600"} + proc = subprocess.Popen(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + shown = "" + deadline = time.time() + 60 + while "retry 1/1 in 600s" not in shown: + assert time.time() < deadline, shown + if select.select([proc.stderr], [], [], 0.2)[0]: + shown += os.read(proc.stderr.fileno(), 4096).decode("utf-8", errors="replace") + time.sleep(0.5) # it is now waiting out the 600 seconds + proc.send_signal(signal.SIGTERM) + assert proc.wait(timeout=30) == 130 # it stopped; it did not wait + finally: + if proc.poll() is None: + proc.kill() + (folder,) = _session_folders(machine, ws) + assert json.loads((folder / "summary.json").read_text(encoding="utf-8"))["outcome"] == "interrupted" +