mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-11-05 04:32:21 +01:00
merge(runtime): reconcile containment with frozen lab
This commit is contained in:
+38
-1
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user