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
55 changes: 52 additions & 3 deletions condor/code_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@
``condor/code_runs.py`` and the action-log row
(:func:`condor.runtime.danger.is_code_execution_call`) are the whole containment;
unattended dry-run sessions refuse it outright in ``condor/agents/risk.py``.

Containment against *accidents* is separate and lives in
:mod:`condor.runtime.code_guard`: the run's timeout can interrupt synchronous
code (a snippet that loops or walks the filesystem on the main thread used to
freeze all of Condor), and ``exit()``, ``input()`` and a few other ways to stop
or stall the process fail on their own line instead.
"""

from __future__ import annotations
Expand All @@ -46,6 +52,7 @@
import condor.reports as reports
from condor.code_runs import CodeRun, get_code_run_store, new_run_id
from condor.memory.paths import CHAT_SLUG
from condor.runtime import code_guard

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -86,6 +93,14 @@ def flush(self):
if buffer is None:
self._stream.flush()

def isatty(self):
# A captured run is never a terminal. Forwarding this to a real tty is
# how ``help()`` decided to start an interactive pager on the main
# thread and wait for a keypress that would never come.
if _CAPTURE.get() is not None:
return False
return self._stream.isatty()

def __getattr__(self, name):
return getattr(self._stream, name)

Expand Down Expand Up @@ -142,6 +157,7 @@ async def _run_in_task(
source: str,
out: dict,
owner_id: int = 0,
scope: code_guard.GuardScope | None = None,
):
"""The snippet itself, in its own task context.

Expand All @@ -153,6 +169,8 @@ async def _run_in_task(
reads/deletes against it (SEC-196). ``bind_context`` publishes the same
context object the snippet sees, so a ``call_routine`` inside it inherits
this run's server and user instead of an ambient default (FEAT-052).
``scope`` is the run's guard (deadline and policy); it is entered last and
left first, so the harness's own bookkeeping never runs under it.
"""
from condor.primitives import bind_context

Expand All @@ -164,9 +182,13 @@ async def _run_in_task(
bind_context(namespace.get("context")),
):
try:
outcome = eval(compiled, namespace) # noqa: S307 - this IS the feature
if inspect.isawaitable(outcome):
await outcome
with (
code_guard.guarded(scope or code_guard.GuardScope(source), compiled),
code_guard.contain_exit(),
):
outcome = eval(compiled, namespace) # noqa: S307 - this IS the feature
if inspect.isawaitable(outcome):
await outcome
finally:
out["report_id"] = reports.get_last_report_id() or ""

Expand Down Expand Up @@ -217,6 +239,14 @@ async def execute_code(
get_code_run_store().save(run)
return run.to_dict()

refusal = code_guard.check_source(ast.parse(code, CODE_FILENAME))
if refusal:
run.status = "error"
run.error = f"Refused: {refusal}"
run.duration_ms = int((time.monotonic() - started) * 1000)
get_code_run_store().save(run)
return run.to_dict()

# The exact context a routine gets, so the two paths cannot diverge — the
# active server travels as a preference, which is how get_client resolves it.
context = WebRoutineContext(
Expand All @@ -238,6 +268,11 @@ async def execute_code(

_install_stdout_proxy()
out: dict = {"report_id": ""}
# Started here, not with ``started``: resolving the client above is the
# harness's time, not the snippet's.
scope = code_guard.GuardScope(
label=run_id, deadline=time.monotonic() + timeout, budget=timeout
)
task = asyncio.create_task(
_run_in_task(
compiled,
Expand All @@ -247,6 +282,7 @@ async def execute_code(
label or run_id,
out,
owner_id=chat_id,
scope=scope,
)
)
try:
Expand All @@ -255,6 +291,15 @@ async def execute_code(
except (asyncio.TimeoutError, TimeoutError):
run.status = "timeout"
run.error = f"Timed out after {timeout:g}s"
except code_guard.CodeRunDeadline as e:
# The same outcome as above, reached by the guard instead of wait_for:
# the snippet was busy on the main thread and never yielded to be
# cancelled. The traceback is the useful part — it points at the line.
run.status = "timeout"
run.error = f"Timed out after {timeout:g}s ({e})"
run.traceback = _trim_traceback(
"".join(traceback.format_exception(type(e), e, e.__traceback__))
)
except asyncio.CancelledError:
run.status = "timeout"
run.error = "Cancelled"
Expand All @@ -266,6 +311,10 @@ async def execute_code(
"".join(traceback.format_exception(type(e), e, e.__traceback__))
)
finally:
# A task the snippet spawned and never awaited still carries the scope;
# expiring it cuts that straggler at its next line instead of letting it
# run on, unowned, after its run was reported finished.
scope.expire()
run.duration_ms = int((time.monotonic() - started) * 1000)
run.stdout = buffer.getvalue()
run.result = _render_result(namespace)
Expand Down
21 changes: 21 additions & 0 deletions condor/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,27 @@ def mark_read(user_id: int, ids: list[str] | None = None) -> int:
return sum(1 for raw in items if isinstance(raw, dict) and not raw.get("read"))


async def clear(user_id: int) -> None:
"""Delete every notification in ``user_id``'s bucket.

Takes the write lock so a notice recorded mid-clear is not resurrected (or
lost) by the two writes racing. Scoped like :func:`mark_read`: other users'
buckets are left untouched.

A failed write raises rather than being swallowed: unlike a read flag, a
clear the user was told succeeded would quietly come back on the next poll.
"""
if not user_id:
return
key = str(int(user_id))
async with _write_lock:
data = _read_all()
if key not in data:
return
del data[key]
_write_all(data)


def user_for_chat(chat_id: Any) -> int | None:
"""The dashboard user behind a Telegram chat id, or None if there is none.

Expand Down
28 changes: 25 additions & 3 deletions condor/routine_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
iter_agent_slugs,
shared_routines_roots,
)
from condor.runtime import code_guard
from condor.telemetry import taps as telemetry_taps
from routines.base import (
RoutineResult,
Expand Down Expand Up @@ -764,12 +765,24 @@ async def _execute_and_record(
source_name=base_name,
),
primitives.bind_context(ctx),
# Routines are agent-written code on the main loop too: the
# same policy as run_code, and exit() is an error, not a
# shutdown (condor/runtime/code_guard.py).
code_guard.guarded(code_guard.routine_scope(instance_id)),
Comment thread
greptile-apps[bot] marked this conversation as resolved.
code_guard.contain_exit(),
):
cfg = routine.config_class(**config)
raw = await routine.run_fn(cfg, ctx)
result = normalize_result(raw)
except asyncio.CancelledError:
result = RoutineResult(text="Stopped by user")
except code_guard.CodeRunDeadline as e:
# A routine called under a snippet's deadline ran past it. Recorded
# like any failure: a BaseException passing through here would
# leave the instance "running" with no result, forever.
logger.warning(f"Routine {routine.name}[{instance_id}] interrupted: {e}")
result = RoutineResult(text=f"Error: CodeRunDeadline: {e}")
failed = True
except Exception as e:
tb = traceback.format_exc()
logger.error(
Expand Down Expand Up @@ -879,10 +892,13 @@ async def execute(
on_complete=on_complete,
)

# Detached: a background run started from a snippet must not inherit
# that snippet's deadline and die with it (code_guard).
task = asyncio.create_task(
self._run_oneshot(
instance_id, routine, config, server_name, user_id, agent=agent
)
),
context=code_guard.detached_context(),
)
self._tasks[instance_id] = task
return instance_id
Expand Down Expand Up @@ -939,10 +955,13 @@ async def start_continuous(
session_key=session_key,
)

# Detached: a background run started from a snippet must not inherit
# that snippet's deadline and die with it (code_guard).
task = asyncio.create_task(
self._run_continuous(
instance_id, routine, config, server_name, user_id, agent=agent
)
),
context=code_guard.detached_context(),
)
self._tasks[instance_id] = task
return instance_id
Expand Down Expand Up @@ -995,10 +1014,13 @@ async def schedule(
schedule={"type": "interval", "interval_sec": interval_sec},
)

# Detached: a background run started from a snippet must not inherit
# that snippet's deadline and die with it (code_guard).
task = asyncio.create_task(
self._run_scheduled(
instance_id, routine, config, server_name, interval_sec, user_id
)
),
context=code_guard.detached_context(),
)
self._tasks[instance_id] = task
return instance_id
Expand Down
Loading
Loading