merge(runtime): reconcile containment with frozen lab

This commit is contained in:
Alexandre Teixeira
2026-10-01 20:45:45 +01:00
41 changed files with 2219 additions and 51 deletions
+38 -1
View File
@@ -86,6 +86,7 @@ from src.model_profiles import (
tool_schema_profile,
)
from src.tool_execution import AgentExecutionBridge, bind_execution_bridge
from src.agent_runtime.authority import is_internal_tool_request, request_authority_for_http
from src.turn_contract import (
FAMILY_TOOLS, bind_turn_contract, preserve_bound_editor_selected_tools,
requested_capabilities, resolve_turn_contract,
@@ -95,6 +96,15 @@ from src.turn_contract import (
logger = logging.getLogger(__name__)
def _append_internal_chat_context(ctx, message):
tagged = untrusted_context_message("internal tool request", message)
ctx.messages.append(tagged)
routed = getattr(ctx, "route_messages", None)
if routed is not None and routed is not ctx.messages:
routed.append(tagged)
# Track active streams for partial-save safety net
_active_streams: Dict[str, dict] = {}
@@ -2196,7 +2206,10 @@ def setup_chat_routes(
webhook_manager=webhook_manager,
allow_tool_preprocessing=allow_tool_preprocessing,
defer_context_shaping=foreground_policy.enabled,
persist_user_message=not is_internal_tool_request(request),
)
if is_internal_tool_request(request):
_append_internal_chat_context(ctx, message)
# Research injection
research_blocked_by_policy = (
@@ -2648,6 +2661,8 @@ def setup_chat_routes(
)
owner = effective_user(request)
if tool_approval_id:
from src.agent_runtime.authority import require_user_approval_request
require_user_approval_request(request)
pending_tool_approval = tool_approval_store.peek(tool_approval_id)
normalized_owner = str(owner or "").strip().casefold()
if (
@@ -2907,10 +2922,12 @@ def setup_chat_routes(
and pending_tool_approval.continuation_query
else None
),
persist_user_message=not tool_approval_continuation,
persist_user_message=not tool_approval_continuation and not is_internal_tool_request(request),
interaction_mode=chat_mode,
auto_escalated=auto_escalated,
)
if is_internal_tool_request(request):
_append_internal_chat_context(ctx, message)
_research_flags = {"do": do_research} # Mutable container for generator scope
@@ -3349,6 +3366,24 @@ def setup_chat_routes(
"manage_documents", "create_document", "edit_document", "update_document",
}.issubset(disabled_tools),
}
# Capture permission state before schema selection/reconciliation.
# Only deterministic request intent supplies grants, never inventory.
_request_authority = request_authority_for_http(
request, message, owner=_user, session_id=session, workspace=workspace,
history=_turn_history, policy=tool_policy,
active_document=bool(active_doc),
image_attachment=any(str(a.get('mime') or '').startswith('image/')
for a in (ctx.preprocessed.attachment_meta or [])),
capabilities=({'search_browser'} if (
_explicit_browser_intent or _external_discovery_intent
) else ()),
)
if exact_tool_approval is not None:
from src.agent_runtime.authority import RequestAuthority
_request_authority = (
exact_tool_approval.pending.request_authority
or RequestAuthority.empty(owner=_user, session_id=session, workspace=workspace)
).restrict(tool_policy)
_turn_contract = None
# Image models execute directly, not through the text-agent inventory.
# Keep the permission policy above, but do not apply routing omissions
@@ -3477,6 +3512,7 @@ def setup_chat_routes(
warm_tools=_warm_tools,
message=message, history=getattr(sess, "history", []) or [],
)
_request_authority = _request_authority.restrict(_contract_policy)
# Resolution already applies user, owner, and global policy. An
# admitted tool must not later be rejected by the stale
# pre-contract disabled snapshot during execution.
@@ -4415,6 +4451,7 @@ def setup_chat_routes(
cwd=_agent_turn_cwd(sess, client_runtime_context),
forced_tools=_forced_tools,
turn_contract=_turn_contract,
request_authority=_request_authority,
uploaded_files=ctx.uploaded_files,
defer_context_shaping=_foreground_policy.enabled,
external_untrusted_context_seen=external_untrusted_context_seen,
+149 -10
View File
@@ -1,6 +1,7 @@
"""Shell routes — user-facing command execution endpoint."""
import asyncio
import contextlib
import importlib
import json
import logging
@@ -8,6 +9,7 @@ import os
import re
import shlex
import shutil
import signal
import subprocess
import uuid
import tempfile
@@ -49,6 +51,7 @@ from core.platform_compat import (
detached_popen_kwargs,
find_bash,
git_bash_path,
pid_alive,
)
@@ -558,6 +561,20 @@ STREAM_TIMEOUT = 120 # default for short commands
MAX_OUTPUT = 200_000 # truncate limit
TMUX_LOG_DIR = Path(tempfile.gettempdir()) / "odysseus-tmux"
PTY_UNSUPPORTED_ERROR = "pty_unsupported"
# PTY teardown. The PTY child leads its own session (os.setsid), so killing it
# has to signal the whole process group and then confirm the group is gone —
# see _terminate_pty_session.
# ``signal.SIGKILL`` does not exist on native Windows, and this module is
# imported unconditionally by app.py, so resolve the escalation defensively
# rather than at the cost of the whole app failing to start there.
PTY_KILL_ESCALATION = tuple(
sig
for sig in (getattr(signal, "SIGTERM", None), getattr(signal, "SIGKILL", None))
if sig is not None
)
PTY_KILL_GRACE = 1.0 # seconds a signalled session gets to exit
PTY_KILL_POLL_INTERVAL = 0.05 # re-check interval while waiting for it
PTY_KILL_FAILED_HINT = "; processes it started survived the kill and are still running"
class ShellExecRequest(BaseModel):
@@ -662,6 +679,131 @@ async def _exec_shell(command: str, timeout: int = EXEC_TIMEOUT) -> Dict[str, An
return {"stdout": "", "stderr": str(e), "exit_code": -1}
def _session_pgid(pid: int) -> int | None:
"""Process-group id of the session ``pid`` leads, or None if unavailable.
Read this *before* the leader is reaped: once it is, ``getpgid`` fails and
the group id can no longer be recovered from the pid.
"""
getpgid = getattr(os, "getpgid", None)
if getpgid is None: # no process groups (native Windows)
return None
try:
return getpgid(pid)
except OSError:
return None
def _signal_session(pgid: int | None, pid: int, sig: int) -> bool:
"""Send ``sig`` to the whole process group, or to the lone process.
Returns whether anything was signalled, so a caller can tell "the session
is already gone" from "the signal landed". The single-pid fallback
matters: if ``setsid`` did not take effect, or the platform has no process
groups, teardown must still reach the child rather than do nothing.
"""
killpg = getattr(os, "killpg", None)
if pgid is not None and killpg is not None:
try:
killpg(pgid, sig)
return True
except ProcessLookupError:
return False
except OSError:
pass # group signalling refused — fall through to the single pid
try:
os.kill(pid, sig)
return True
except OSError:
return False
def _session_alive(pgid: int | None, pid: int) -> bool:
"""True while any member of the process group still exists.
An unreaped zombie is still signallable, so a True here can also mean the
leader has exited but not yet been collected. Without a group id this can
only speak for the child itself, not for anything it spawned.
"""
killpg = getattr(os, "killpg", None)
if pgid is not None and killpg is not None:
try:
killpg(pgid, 0)
except ProcessLookupError:
return False # ESRCH — no member of the group is left
except OSError:
# Anything else (EPERM when the group holds a process we may not
# signal, EINVAL) answers the probe without proving the group is
# gone. Only ESRCH does that, so treat the rest as still running:
# reporting a surviving session as contained is the one outcome
# teardown must never produce.
return True
return True
return pid_alive(pid)
async def _await_session_exit(proc, pgid: int | None, pid: int) -> bool:
"""Wait up to the grace period for the leader and its group to go away."""
loop = asyncio.get_running_loop()
deadline = loop.time() + PTY_KILL_GRACE
while True:
remaining = deadline - loop.time()
if proc.returncode is None and remaining > 0:
# Reap the leader, otherwise its own zombie keeps the group alive
# and the liveness probe below can never come back clean.
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(proc.wait(), remaining)
if not _session_alive(pgid, pid):
return True
if loop.time() >= deadline:
return False
await asyncio.sleep(PTY_KILL_POLL_INTERVAL)
async def _terminate_pty_session(proc) -> bool:
"""Kill the PTY child and every process in the session it leads.
The child is spawned under ``os.setsid``, so it leads its own session and
process group. Signalling only the leader is strictly worse than never
calling ``setsid`` at all: the descendants are detached from the server's
group too, so nothing will ever reach them, while the caller reports the
command as terminated. Signal the group instead, escalate to SIGKILL if it
outlives the grace period, and return whether the session is actually gone
so the caller can say so rather than assume it.
"""
pid = getattr(proc, "pid", None)
if pid is None:
return True
pgid = _session_pgid(pid)
for sig in PTY_KILL_ESCALATION:
if not _signal_session(pgid, pid, sig):
break # nothing left to signal
if await _await_session_exit(proc, pgid, pid):
return True
return not _session_alive(pgid, pid)
async def _terminate_pty_session_quietly(proc) -> None:
"""Best-effort :func:`_terminate_pty_session` for paths with no reader.
The client-disconnect and exception paths have nowhere left to report a
containment failure to, so they log it instead of raising over the top of
whatever is already going wrong.
"""
pid = getattr(proc, "pid", None)
try:
contained = await _terminate_pty_session(proc)
except Exception:
logger.exception("PTY session teardown failed for pid %s", pid)
return
if not contained:
logger.warning(
"PTY session for pid %s survived teardown; it may still be running",
pid,
)
async def _generate_pty(cmd: str, timeout: int, request: Request):
"""Run command in a pseudo-TTY so tqdm/progress bars work natively."""
if not PTY_SUPPORTED:
@@ -702,16 +844,17 @@ async def _generate_pty(cmd: str, timeout: int, request: Request):
try:
while not process_done.is_set():
if deadline and loop.time() > deadline:
proc.kill()
await proc.wait()
yield f"data: {json.dumps({'stream': 'stderr', 'data': f'Command timed out after {timeout}s'})}\n\n"
contained = await _terminate_pty_session(proc)
msg = f"Command timed out after {timeout}s"
if not contained:
msg += PTY_KILL_FAILED_HINT
yield f"data: {json.dumps({'stream': 'stderr', 'data': msg})}\n\n"
yield f"data: {json.dumps({'exit_code': -1})}\n\n"
return
# Check client disconnect
if await request.is_disconnected():
proc.kill()
await proc.wait()
await _terminate_pty_session_quietly(proc)
return
# Read available data from PTY
@@ -773,11 +916,7 @@ async def _generate_pty(cmd: str, timeout: int, request: Request):
yield f"data: {json.dumps({'exit_code': proc.returncode})}\n\n"
except Exception as e:
try:
proc.kill()
await proc.wait()
except ProcessLookupError:
pass
await _terminate_pty_session_quietly(proc)
yield f"data: {json.dumps({'stream': 'stderr', 'data': str(e)})}\n\n"
yield f"data: {json.dumps({'exit_code': -1})}\n\n"
finally:
+16 -1
View File
@@ -534,11 +534,13 @@ async def _run_skill_test_job(
messages=None,
transcript=None,
exact_approval=None,
request_authority=None,
):
"""Background coroutine: run the skill in an agent loop, capture a condensed
log + transcript, then have the judge grade it. Writes into _skill_test_jobs."""
import json as _json
from src.agent_loop import stream_agent_loop
from src.agent_runtime.authority import RequestAuthority
job = _skill_test_jobs.get(key)
if job is None:
@@ -559,6 +561,9 @@ async def _run_skill_test_job(
url, model, messages, headers=headers,
temperature=0.3, max_tokens=0, max_rounds=8, owner=owner,
exact_approval=exact_approval,
request_authority=(request_authority or (
exact_approval.pending.request_authority if exact_approval is not None else None
) or RequestAuthority.empty(owner=owner)),
):
if not chunk.startswith("data: ") or chunk.strip() == "data: [DONE]":
continue
@@ -1039,6 +1044,7 @@ async def _run_skill_audit_arm(messages: list[dict], url, model, headers, owner,
"""Run one audit arm in the agent loop; return transcript, stats, approval."""
import json as _json
from src.agent_loop import stream_agent_loop
from src.agent_runtime.authority import RequestAuthority, active_request_authority
transcript = []
approval_required = None
stats = {"turns": 0, "tool_calls": 0}
@@ -1051,6 +1057,7 @@ async def _run_skill_audit_arm(messages: list[dict], url, model, headers, owner,
url, model, messages, headers=headers,
temperature=0.3, max_tokens=4096, max_rounds=8,
owner=owner, workload=workload, suppress_skills=True,
request_authority=(active_request_authority() or RequestAuthority.empty(owner=owner)),
):
# Streams can include an SSE event line before the data line,
# notably `event: error`. Do not silently discard those failures.
@@ -2001,6 +2008,9 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
user = _owner(request)
body = await request.json()
task = (body.get("task") or "").strip()
from src.agent_runtime.authority import RequestAuthority, request_authority_for_http
request_authority = (request_authority_for_http(request, task, owner=user)
if task else RequestAuthority.empty(owner=user))
skills = skills_manager.load(owner=user)
match = next((s for s in skills if s.get("name") == skill_id or s.get("id") == skill_id), None)
@@ -2064,9 +2074,12 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
"model": model,
"headers": headers,
"owner": user,
"request_authority": request_authority,
},
}
_asyncio.create_task(_run_skill_test_job(key, name, md, task, url, model, headers, user, skills_manager))
_asyncio.create_task(_run_skill_test_job(
key, name, md, task, url, model, headers, user, skills_manager,
request_authority=request_authority))
return {"ok": True, "status": "running", "skill": name, "model": model}
@router.post("/{skill_id}/test-approval")
@@ -2074,6 +2087,8 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
"""Resume a manual skill test with one exact server-sealed action."""
import asyncio as _asyncio
from src.tool_approvals import tool_approval_store
from src.agent_runtime.authority import require_user_approval_request
require_user_approval_request(request)
user = _owner(request)
skills = skills_manager.load(owner=user)
+13
View File
@@ -25,6 +25,14 @@ from routes.prefs_routes import _load_for_user, _save_for_user
logger = logging.getLogger(__name__)
def _seal_request_task_authority(request, prompt, task_type, action, owner):
from src.agent_runtime.authority import MISSING_AUTHORITY, is_internal_tool_request, seal_task_authority
# A tool HTTP call starts another ASGI context. Its model-produced body is
# not a fresh user request, even though the internal token authenticates it.
parent = None if is_internal_tool_request(request) else MISSING_AUTHORITY
return seal_task_authority(prompt, task_type, action, owner=owner, parent_authority=parent)
def _maybe_cascade_calendar_event(task) -> None:
"""Delete the linked calendar event when a cookbook_serve task is
removed. Two lookup strategies:
@@ -530,6 +538,8 @@ def setup_task_routes(task_scheduler) -> APIRouter:
owner=user,
name=name,
prompt=req.prompt,
request_authority_json=_seal_request_task_authority(
request, req.prompt, req.task_type, req.action, user),
task_type=req.task_type,
action=req.action,
schedule=req.schedule,
@@ -737,6 +747,9 @@ def setup_task_routes(task_scheduler) -> APIRouter:
task.task_type = req.task_type
if req.action is not None:
task.action = req.action
if any(value is not None for value in (req.prompt, req.task_type, req.action)):
task.request_authority_json = _seal_request_task_authority(
request, task.prompt, task.task_type, task.action, user)
if req.output_target is not None:
task.output_target = req.output_target
if req.model is not None: