Skip to content
Merged
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
17 changes: 17 additions & 0 deletions coworker/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down
77 changes: 71 additions & 6 deletions coworker/headless/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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)))
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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


Expand Down
71 changes: 71 additions & 0 deletions coworker/sandbox/inside.py
Original file line number Diff line number Diff line change
@@ -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)
9 changes: 9 additions & 0 deletions coworker/web/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 6 additions & 1 deletion docs/headless.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Loading
Loading