Compare commits

..
Author SHA1 Message Date
StressTestor 166a6e9cf6 fix(agent): drop the empty assistant turn from an approved-action replay
The approved-action replay appends the sealed tool result with no assistant
prose for that round, which produced an assistant message with content "".
Anthropic's Messages API rejects a non-final assistant message with empty
content, so a resumed turn after a tool approval failed before the model saw
the result. A turn carrying neither prose nor reasoning has nothing to say to
any provider, so it is no longer appended. A round with prose, and a
reasoning-only round that DeepSeek thinking mode needs, both still append.
2026-08-19 03:36:28 -06:00
27 changed files with 156 additions and 1681 deletions
-18
View File
@@ -30,20 +30,11 @@ def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) ->
"""
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
try:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=indent)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
finally:
# Directly unlink to avoid a check-then-act race condition.
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
try:
os.unlink(tmp)
except OSError:
pass
def atomic_write_text(path: str, text: str) -> None:
@@ -51,17 +42,8 @@ def atomic_write_text(path: str, text: str) -> None:
raise TypeError("atomic_write_text expects a string")
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
try:
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
finally:
# Directly unlink to avoid a check-then-act race condition.
# Swallows FileNotFoundError (on success path) and other cleanup OSErrors.
try:
os.unlink(tmp)
except OSError:
pass
+1 -51
View File
@@ -8,11 +8,6 @@ These are simple datacontainers. All persistence is handled by SessionManager.
from dataclasses import dataclass
from typing import Dict, List, Any, Optional, TYPE_CHECKING
from src.tool_approval_scopes import (
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
CHAT_SESSION_APPROVAL_DECISION,
)
if TYPE_CHECKING:
from .session_manager import SessionManager
@@ -36,35 +31,6 @@ set_session_manager = set_session_manager_instance
get_session_manager = get_session_manager_instance
def _history_grants_chat_session_approval(
history: List["ChatMessage"],
session_id: str,
) -> bool:
"""Return whether this exact chat has a resolved session-scope grant."""
expected_session = str(session_id or "")
if not expected_session:
return False
for message in reversed(history or []):
metadata = getattr(message, "metadata", None)
if not isinstance(metadata, dict):
continue
tool_events = metadata.get("tool_events")
if not isinstance(tool_events, list):
continue
for event in reversed(tool_events):
ask_user = event.get("ask_user") if isinstance(event, dict) else None
if not isinstance(ask_user, dict):
continue
if (
ask_user.get("kind") == "tool_approval"
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
and str(ask_user.get("session_id") or "") == expected_session
):
return True
return False
@dataclass
class ChatMessage:
"""A single chat message."""
@@ -150,27 +116,11 @@ class Session:
the model. Display/history-load paths use the raw ``history`` and are
unaffected.
"""
messages = [
return [
msg.to_dict()
for msg in self.history
if (msg.metadata or {}).get("source") != "slash"
]
if not _history_grants_chat_session_approval(self.history, self.id):
return messages
# Keep the grant close to the latest user request so route-neutral
# compaction/trimming preserves it. Copy the metadata instead of
# mutating the durable transcript object.
for index in range(len(messages) - 1, -1, -1):
if messages[index].get("role") != "user":
continue
message = dict(messages[index])
metadata = dict(message.get("metadata") or {})
metadata[CHAT_SESSION_APPROVAL_CONTEXT_MARKER] = True
message["metadata"] = metadata
messages[index] = message
break
return messages
def get(self, key: str, default=None):
"""Dict-like access for compatibility."""
+5 -20
View File
@@ -624,8 +624,6 @@ async def build_chat_context(
agent_mode: bool = False,
allow_tool_preprocessing: bool = True,
defer_context_shaping: bool = False,
continuation_context_message: str | None = None,
persist_user_message: bool = True,
) -> ChatContext:
"""Build the full context (preface + messages) for an LLM call.
@@ -649,14 +647,14 @@ async def build_chat_context(
# Add user message to history. Nobody/incognito uses a request-local
# transcript store instead of session history so stale saved chats cannot
# bleed into context and the turn is not persisted.
if persist_user_message and incognito:
if incognito:
user_meta = {"attachments": preprocessed.attachment_meta} if preprocessed.attachment_meta else None
_append_incognito_message(session_id, "user", preprocessed.user_content, user_meta)
elif persist_user_message:
else:
add_user_message(sess, chat_handler, preprocessed, incognito=False)
# Fire events
if persist_user_message and not incognito:
if not incognito:
fire_message_event(request, webhook_manager, session_id, sess, message, compare_mode)
# Resolve owner-scoped prefs/context. Browser requests keep the cookie user;
@@ -668,12 +666,7 @@ async def build_chat_context(
getattr(chat_handler, "upload_handler", None),
getattr(sess, "owner", None),
)
context_message = (
str(continuation_context_message).strip()
if continuation_context_message
else message
)
casual_low_signal = _is_casual_low_signal(context_message)
casual_low_signal = _is_casual_low_signal(message)
# Memory enabled?
mem_enabled = not incognito and not no_memory and uprefs.get("memory_enabled", True)
@@ -710,15 +703,7 @@ async def build_chat_context(
# Build context preface
# The stream path uses enhanced_message (with CoT/preprocessing applied),
# the sync path uses text_for_context.
_ctx_msg = (
context_message
if continuation_context_message
else (
preprocessed.enhanced_message
if use_enhanced_message
else preprocessed.text_for_context
)
)
_ctx_msg = preprocessed.enhanced_message if use_enhanced_message else preprocessed.text_for_context
_preface_kwargs = dict(
message=_ctx_msg,
session=sess,
+31 -130
View File
@@ -89,65 +89,6 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
return None
def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
"""Persist a consumed approval decision on its existing tool event."""
approval_key = str(approval_id or "")
normalized_decision = str(decision or "").strip().lower()
if not approval_key or normalized_decision not in {"approve", "approve_task", "deny"}:
return False
message_id = None
resolved_metadata = None
for item in reversed(getattr(sess, "history", []) or []):
metadata = getattr(item, "metadata", None)
if not isinstance(metadata, dict):
continue
tool_events = metadata.get("tool_events")
if not isinstance(tool_events, list):
continue
for event in reversed(tool_events):
ask_user = event.get("ask_user") if isinstance(event, dict) else None
if not isinstance(ask_user, dict):
continue
if str(ask_user.get("approval_id") or "") != approval_key:
continue
ask_user["resolved"] = normalized_decision
message_id = metadata.get("_db_id")
resolved_metadata = {
key: value for key, value in metadata.items() if key != "_db_id"
}
break
if resolved_metadata is not None:
break
if resolved_metadata is None or not message_id:
return False
db = SessionLocal()
try:
db_message = db.query(DBChatMessage).filter(
DBChatMessage.id == message_id,
DBChatMessage.session_id == str(getattr(sess, "id", "")),
).first()
if db_message is None:
return False
db_message.meta_data = json.dumps(resolved_metadata)
db.commit()
return True
except Exception:
db.rollback()
logger.exception("Failed to persist tool approval resolution")
return False
finally:
db.close()
async def _tool_approval_resolution_stream(decision: str) -> AsyncGenerator[str, None]:
yield f"data: {json.dumps({'type': 'tool_approval_resolved', 'decision': decision})}\n\n"
yield "data: [DONE]\n\n"
def _chat_candidate_request_factory(
messages,
fallback_context_length: int = 0,
@@ -976,7 +917,6 @@ def setup_chat_routes(
exact_tool_approval = None
pending_tool_approval = None
retired_tool_approval_taint = False
external_untrusted_context_seen = False
tool_approval_continuation = False
# Workspace: confine the agent's file/shell tools to this folder.
workspace, workspace_rejected = _resolve_request_workspace(
@@ -1110,14 +1050,14 @@ def setup_chat_routes(
)
try:
# Attachment-only sends and approval controls may omit message text.
# Attachment-only sends: skip the message-required check when the
# user has attached one or more files (the attachment IS the action).
_has_atts = (
bool(body and isinstance(body.get("attachments"), list) and body["attachments"])
or bool(form_data.get("attachments"))
)
message, session = coerce_message_and_session(
body, message, session, session_manager,
allow_empty=(_has_atts or bool(tool_approval_id)),
body, message, session, session_manager, allow_empty=_has_atts,
)
# Verify ownership AFTER coerce (which may resolve a default session)
# but BEFORE loading. Prevents cross-user session hijack.
@@ -1136,14 +1076,8 @@ def setup_chat_routes(
409,
"This tool approval is invalid, expired, or belongs to another thread.",
)
pending_taint = bool(
pending_tool_approval.external_untrusted_context_seen
)
external_untrusted_context_seen = (
external_untrusted_context_seen or pending_taint
)
decision = str(tool_approval_decision or "").strip().lower()
if decision not in {"approve", "approve_task", "deny"}:
if decision not in {"approve", "deny"}:
raise HTTPException(400, "Invalid tool approval decision.")
if plan_mode:
raise HTTPException(
@@ -1157,46 +1091,37 @@ def setup_chat_routes(
session_id=session,
)
tool_approval_continuation = True
if (
decision in {"approve", "approve_task"}
and exact_tool_approval is None
):
if decision == "approve" and exact_tool_approval is None:
raise HTTPException(
409,
"This tool approval could not be consumed.",
)
if not _mark_tool_approval_resolved(
sess,
tool_approval_id,
decision,
):
logger.warning(
"Tool approval %s was consumed but its persisted card could not be marked resolved",
tool_approval_id,
if decision == "approve":
message = (
f"Approved the exact {pending_tool_approval.tool_name} action "
"shown above once."
)
if decision == "deny":
return StreamingResponse(
_tool_approval_resolution_stream(decision),
media_type="text/event-stream",
)
# Approval is a control-plane continuation, not a new user turn.
# Reuse the sealed interrupted request only for internal context,
# retrieval, and policy reconstruction; never persist or display it.
message = pending_tool_approval.continuation_query
# The sealed server record, not mutable composer state,
# restores the original action workspace.
workspace = pending_tool_approval.workspace or None
workspace_rejected = None
if pending_tool_approval.document_id:
active_doc_id = pending_tool_approval.document_id
# Restore only the coarse request toggle needed by the exact
# sealed action. Current privilege, global-disable, incognito,
# compare, and tool-policy gates still run.
# The approval click is the per-turn opt-in for this exact
# sealed action. Restore only the coarse request toggle
# that would otherwise disable it because the synthetic
# "Approved…" message no longer resembles the original
# shell/web request. Current privilege, global-disable,
# incognito, compare, and tool-policy gates still run.
if pending_tool_approval.tool_name == "bash":
allow_bash = "true"
if pending_tool_approval.tool_name in WEB_TOOL_NAMES:
allow_web_search = "true"
_search_enabled = True
else:
message = (
f"Denied the {pending_tool_approval.tool_name} action shown above."
)
chat_mode = "agent"
else:
# A normal user message supersedes the card that was waiting
@@ -1207,9 +1132,6 @@ def setup_chat_routes(
owner=owner,
session_id=session,
)
external_untrusted_context_seen = (
external_untrusted_context_seen or retired_tool_approval_taint
)
_reconcile_selected_route_from_request(request, sess, session, form_data, owner=owner)
if _clear_orphaned_session_endpoint(sess, owner=owner):
raise HTTPException(400, "Selected model endpoint was removed. Pick another model in Settings.")
@@ -1339,14 +1261,6 @@ def setup_chat_routes(
agent_mode=(chat_mode == "agent"),
allow_tool_preprocessing=allow_tool_preprocessing,
defer_context_shaping=foreground_policy.enabled,
continuation_context_message=(
pending_tool_approval.continuation_query
if exact_tool_approval
and pending_tool_approval
and pending_tool_approval.continuation_query
else None
),
persist_user_message=not tool_approval_continuation,
)
_research_flags = {"do": do_research} # Mutable container for generator scope
@@ -1749,11 +1663,7 @@ def setup_chat_routes(
if foreground_policy.enabled
else ctx.messages
)
messages = (
list(context_source)
if tool_approval_continuation
else _ensure_current_request_is_latest_user(context_source, message)
)
messages = _ensure_current_request_is_latest_user(context_source, message)
# Auto-compact notification
if ctx.was_compacted:
@@ -2224,10 +2134,7 @@ def setup_chat_routes(
incognito=incognito, compare_mode=compare_mode,
character_name=ctx.preset.character_name,
owner=_user,
allow_background_extraction=(
not tool_policy.block_all_tool_calls
and not tool_approval_continuation
),
allow_background_extraction=not tool_policy.block_all_tool_calls,
)
_stream_set(session, status="done")
yield chunk
@@ -2316,17 +2223,17 @@ def setup_chat_routes(
plan_mode=plan_mode,
approved_plan=approved_plan or None,
workspace=workspace or None,
relevant_tools=(
set(pending_tool_approval.selected_tools)
if exact_tool_approval
and pending_tool_approval
and pending_tool_approval.selected_tools
else None
),
forced_tools=_forced_tools,
uploaded_files=ctx.uploaded_files,
defer_context_shaping=_foreground_policy.enabled,
external_untrusted_context_seen=external_untrusted_context_seen,
external_untrusted_context_seen=bool(
retired_tool_approval_taint
or (
tool_approval_continuation
and pending_tool_approval
and pending_tool_approval.external_untrusted_context_seen
)
),
exact_approval=exact_tool_approval,
):
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
@@ -2494,14 +2401,8 @@ def setup_chat_routes(
agent_tool_calls=_agent_tool_calls,
skills_manager=skills_manager,
owner=_user,
extract_skills=(
user_requested_agent
and not tool_approval_continuation
),
allow_background_extraction=(
not tool_policy.block_all_tool_calls
and not tool_approval_continuation
),
extract_skills=user_requested_agent,
allow_background_extraction=not tool_policy.block_all_tool_calls,
)
_stream_set(session, status="done")
yield chunk
-3
View File
@@ -1603,9 +1603,6 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
decision=decision,
owner=user,
session_id=None,
# The button here says "Allow once" and there is no chat to carry a
# scope into, so the gate must re-arm behind the sealed action.
allow_continuation=False,
)
if decision == "approve" and exact_approval is None:
+2 -7
View File
@@ -327,12 +327,7 @@ def list_models():
@app.post("/v1/images/generations")
def generate(req: ImageRequest):
# The served model is the one this process was launched with. `req.model`
# is accepted for OpenAI wire compatibility and ignored, matching
# scripts/diffusion_server.py: honouring it would let a caller point the
# generator at any local directory or Hugging Face repo, and the HiDream
# branch runs a python script from inside that directory.
model = _args.model
model = req.model or _args.model
width, height = _size(req.size)
out_images = []
count = max(1, min(int(req.n or 1), 4))
@@ -398,7 +393,7 @@ async def edit_image(
size: str = Form("1024x1024"),
response_format: str = Form("b64_json"),
):
active_model = _args.model # pinned; see generate()
active_model = model or _args.model
if _is_lama_inpaint(active_model) or _is_ddcolor(active_model):
image_raw = await image.read()
mask_raw = await mask.read() if mask is not None else None
+1 -16
View File
@@ -3467,10 +3467,7 @@ async def stream_agent_loop(
and exact_approval.pending.external_untrusted_context_seen
)
or messages_contain_external_untrusted_context(messages)
),
approval_gate_bypassed=bool(
exact_approval and exact_approval.allow_remaining_actions
),
)
)
mcp_mgr = get_mcp_manager()
prep_timings: Dict[str, float] = {}
@@ -5708,16 +5705,6 @@ async def stream_agent_loop(
"policy": "exact_tool_approval_target",
}
else:
# The approval click becomes a synthetic user turn. Seal the
# actual server-selected candidates now so that continuation
# does not lose memory, skills, MCP, documents, or other
# ToolIndex/RAG-selected tools by classifying that synthetic text.
approval_selected_tools = set(_relevant_tools or ())
approval_selected_tools.update(
name for name in _tool_names_sent if name
)
approval_selected_tools.add(block.tool_type)
approval_selected_tools.difference_update(disabled_tools)
pending_approval = tool_approval_store.create(
owner=owner,
session_id=session_id,
@@ -5745,8 +5732,6 @@ async def stream_agent_loop(
external_untrusted_context_seen=(
run_security.external_untrusted_context_seen
),
selected_tools=approval_selected_tools,
continuation_query=_retrieval_query or _last_user,
capabilities=capabilities_for_action(
block.tool_type,
block.content,
+1 -1
View File
@@ -4,7 +4,7 @@ import os
from src.runtime_paths import get_app_root, get_default_data_dir
APP_VERSION = "1.0.3"
APP_VERSION = "1.0.2"
# Base paths
BASE_DIR = os.path.join(get_app_root(), "")
-35
View File
@@ -1,35 +0,0 @@
"""Shared wire values and scope markers for tool approval continuations."""
from __future__ import annotations
from enum import Enum
# Keep the existing wire values so the current route and no-build frontend do
# not need a second protocol migration. ``approve`` no longer means one action;
# it now selects chat-session scope.
TASK_APPROVAL_DECISION = "approve_task"
CHAT_SESSION_APPROVAL_DECISION = "approve"
DENY_APPROVAL_DECISION = "deny"
# Session.get_context_messages() adds this server-owned marker only when the
# session history contains a matching, resolved chat-session approval.
CHAT_SESSION_APPROVAL_CONTEXT_MARKER = "_tool_approval_chat_session_granted"
class ToolApprovalScope(str, Enum):
# Surfaces without a resumable chat (the skill tester, unattended audits)
# keep the original one-use meaning: the sealed action runs and the gate
# re-arms immediately for anything after it.
SINGLE_ACTION = "single_action"
TASK = "task"
CHAT_SESSION = "chat_session"
def scope_for_decision(decision: object) -> ToolApprovalScope | None:
normalized = str(decision or "").strip().lower()
if normalized == TASK_APPROVAL_DECISION:
return ToolApprovalScope.TASK
if normalized == CHAT_SESSION_APPROVAL_DECISION:
return ToolApprovalScope.CHAT_SESSION
return None
+15 -136
View File
@@ -1,9 +1,8 @@
"""Opaque exact-action approvals with explicit task and chat scopes.
"""Opaque, exact, one-use approvals for tainted model-requested actions.
The server still seals and claims the first displayed action exactly once. The
selected scope then bypasses only the automatic post-external-context approval
gate for the rest of the resumed task or chat session. Browser-visible fields
are display copies, never authority.
The model may propose an action after untrusted context, but only the server
stores and later executes the exact approved tool input. Browser-visible
fields are display copies, never authority.
"""
from __future__ import annotations
@@ -17,13 +16,6 @@ import time
from dataclasses import dataclass, field
from typing import Any
from src.tool_approval_scopes import (
CHAT_SESSION_APPROVAL_DECISION,
DENY_APPROVAL_DECISION,
TASK_APPROVAL_DECISION,
ToolApprovalScope,
scope_for_decision,
)
from src.tool_capabilities import ToolCapabilities, capabilities_for_action
@@ -41,51 +33,6 @@ def _normalized_workspace(workspace: Any) -> str:
return os.path.realpath(os.path.expanduser(workspace))
_MAX_APPROVAL_SELECTED_TOOLS = 512
_MAX_APPROVAL_TOOL_NAME_CHARS = 512
_MAX_APPROVAL_CONTINUATION_QUERY_CHARS = 4000
def _normalized_selected_tools(
selected_tools: Any,
*,
required_tool: Any = None,
) -> tuple[str, ...]:
if isinstance(selected_tools, str):
selected_tools = (selected_tools,)
try:
values = selected_tools or ()
names = {
name.strip()
for name in values
if (
isinstance(name, str)
and name.strip()
and len(name.strip()) <= _MAX_APPROVAL_TOOL_NAME_CHARS
)
}
required_name = str(required_tool or "").strip()
if required_name and len(required_name) <= _MAX_APPROVAL_TOOL_NAME_CHARS:
names.add(required_name)
ordered = sorted(names)
if len(ordered) <= _MAX_APPROVAL_SELECTED_TOOLS:
return tuple(ordered)
kept = ordered[:_MAX_APPROVAL_SELECTED_TOOLS]
if required_name and required_name in names and required_name not in kept:
kept[-1] = required_name
kept.sort()
return tuple(kept)
except TypeError:
return ()
def _normalized_continuation_query(value: Any) -> str:
# The query is server-derived from the interrupted run and already lives in
# session history. Keep the pending copy bounded because approvals are held
# in memory until consumed or expired.
return str(value or "").strip()[:_MAX_APPROVAL_CONTINUATION_QUERY_CHARS]
def _canonical_digest(payload: dict[str, Any]) -> str:
encoded = json.dumps(
payload,
@@ -113,8 +60,6 @@ def _binding_payload(
document_version: Any,
document_digest: Any,
external_untrusted_context_seen: bool,
selected_tools: Any,
continuation_query: Any,
effects: tuple[str, ...],
result_integrity: str,
) -> dict[str, Any]:
@@ -131,10 +76,6 @@ def _binding_payload(
),
"document_digest": str(document_digest or "").strip().lower(),
"external_untrusted_context_seen": bool(external_untrusted_context_seen),
"selected_tools": list(
_normalized_selected_tools(selected_tools, required_tool=tool_name)
),
"continuation_query": _normalized_continuation_query(continuation_query),
"effects": list(effects),
"result_integrity": str(result_integrity),
}
@@ -158,47 +99,26 @@ class PendingToolApproval:
digest: str
created_at: float
expires_at: float
# Server-only continuation state. Both fields are digest-bound and never
# exposed in the browser payload.
selected_tools: tuple[str, ...] = ()
continuation_query: str = ""
def public_payload(self, *, reason: str | None = None) -> dict[str, Any]:
return {
"kind": "tool_approval",
"approval_id": self.approval_id,
# The browser already owns this chat id. Persisting it with the
# resolved card lets history-derived session grants remain bound to
# this exact chat and prevents inheritance by a forked session.
"session_id": self.session_id,
"question": "Allow this task to continue?",
"question": "Allow this exact action once?",
"description": reason or (
"Untrusted context influenced this run, so continuing with "
"otherwise-gated actions needs your explicit approval."
"Untrusted context influenced this run, so this action needs "
"your explicit approval."
),
"options": [
{
"label": "Allow for this task",
"value": TASK_APPROVAL_DECISION,
"description": (
"Execute the sealed action and allow every otherwise-gated "
"action needed to finish this request. Current tool, account, "
"workspace, and sandbox restrictions still apply."
),
},
{
"label": "Allow for this chat session",
"value": CHAT_SESSION_APPROVAL_DECISION,
"description": (
"Execute the sealed action and stop asking at this gate for "
"later requests in this chat. Current tool, account, workspace, "
"and sandbox restrictions still apply."
),
"label": "Allow once",
"value": "approve",
"description": "Execute only the sealed action shown here.",
},
{
"label": "Deny",
"value": DENY_APPROVAL_DECISION,
"description": "Do not execute the proposed action.",
"value": "deny",
"description": "Do not execute it.",
},
],
"action": {
@@ -217,23 +137,12 @@ class PendingToolApproval:
@dataclass
class ExactToolApproval:
"""A consumed exact first action plus an explicit continuation scope."""
"""A consumed grant that the dispatcher can claim exactly once."""
pending: PendingToolApproval
scope: ToolApprovalScope = ToolApprovalScope.TASK
# The seam consumed by agent_loop. Both chat-card allow choices cover the
# complete resumed task, because one-action scope there immediately
# re-entered the same gate on the next round. Callers with no resumable
# chat still get SINGLE_ACTION, which leaves the gate armed behind the
# sealed action.
allow_remaining_actions: bool = True
_claimed: bool = field(default=False, init=False, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
@property
def grants_chat_session(self) -> bool:
return self.scope is ToolApprovalScope.CHAT_SESSION
def _matches_unlocked(
self,
*,
@@ -266,8 +175,6 @@ class ExactToolApproval:
external_untrusted_context_seen=(
self.pending.external_untrusted_context_seen
),
selected_tools=self.pending.selected_tools,
continuation_query=self.pending.continuation_query,
effects=effects,
result_integrity=result_integrity,
)
@@ -348,8 +255,6 @@ class ToolApprovalStore:
document_id: Any = None,
document_version: Any = None,
document_digest: Any = None,
selected_tools: Any = None,
continuation_query: Any = None,
external_untrusted_context_seen: bool,
capabilities: ToolCapabilities,
) -> PendingToolApproval:
@@ -367,8 +272,6 @@ class ToolApprovalStore:
document_version=document_version,
document_digest=document_digest,
external_untrusted_context_seen=external_untrusted_context_seen,
selected_tools=selected_tools,
continuation_query=continuation_query,
effects=effects,
result_integrity=result_integrity,
)
@@ -391,8 +294,6 @@ class ToolApprovalStore:
digest=_canonical_digest(payload),
created_at=now,
expires_at=now + self._ttl_seconds,
selected_tools=tuple(payload["selected_tools"]),
continuation_query=payload["continuation_query"],
)
with self._lock:
self._purge_expired_locked(now)
@@ -430,17 +331,7 @@ class ToolApprovalStore:
decision: Any,
owner: Any,
session_id: Any,
allow_continuation: bool = True,
) -> ExactToolApproval | None:
"""Consume a pending approval.
``allow_continuation`` is the caller's assertion that it owns a
resumable conversation the granted scope can apply to. Callers without
one (the skill tester, unattended audits) pass ``False`` and get the
original one-use grant, so a button labelled "Allow once" cannot widen
into a run-long bypass just because the chat card reuses the same wire
value.
"""
now = time.time()
with self._lock:
self._purge_expired_locked(now)
@@ -457,21 +348,9 @@ class ToolApprovalStore:
# another owner's pending action.
return None
self._pending.pop(approval_key, None)
normalized_decision = str(decision or "").strip().lower()
scope = scope_for_decision(normalized_decision)
if scope is None:
if str(decision or "").strip().lower() != "approve":
return None
if not allow_continuation:
return ExactToolApproval(
pending,
scope=ToolApprovalScope.SINGLE_ACTION,
allow_remaining_actions=False,
)
return ExactToolApproval(
pending,
scope=scope,
allow_remaining_actions=True,
)
return ExactToolApproval(pending)
def peek(self, approval_id: Any) -> PendingToolApproval | None:
now = time.time()
+2 -20
View File
@@ -14,7 +14,6 @@ from enum import Enum
from types import MappingProxyType
from typing import Any, Iterable, Mapping
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
from src.tool_security import BUILTIN_EMAIL_TOOLS
@@ -619,30 +618,13 @@ class ToolRunSecurityContext:
external_untrusted_context_seen: bool = False
external_sources: list[str] = field(default_factory=list)
run_id: str = field(default_factory=lambda: uuid.uuid4().hex)
# Task-scope approval sets this for the resumed in-memory run. Chat-scope
# approval is projected from the server-owned session history marker below.
# The bypass affects only this automatic gate; current tool policy, ownership,
# workspace confinement, and execution/sandbox restrictions still apply.
approval_gate_bypassed: bool = False
def observe_messages(self, messages: Iterable[dict]) -> None:
"""Apply server-owned chat scope and promote untrusted prompt context."""
message_list = list(messages or ())
if any(
isinstance(message, dict)
and isinstance(message.get("metadata"), dict)
and message["metadata"].get(
CHAT_SESSION_APPROVAL_CONTEXT_MARKER
) is True
for message in message_list
):
self.approval_gate_bypassed = True
if messages_contain_external_untrusted_context(message_list):
"""Promote any server-labelled untrusted prompt context into the gate."""
if messages_contain_external_untrusted_context(messages):
self.external_untrusted_context_seen = True
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
if self.approval_gate_bypassed:
return ToolGateDecision(True)
if not self.external_untrusted_context_seen:
return ToolGateDecision(True)
capabilities = capabilities_for_action(tool_name, content)
+13 -24
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
import re
from contextvars import ContextVar
from datetime import datetime, timedelta, timezone, tzinfo
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional
@@ -65,31 +65,19 @@ def format_utc_offset(offset_min: Optional[int]) -> str:
return f"{sign}{hours:02d}:{minutes:02d}"
def _zoneinfo_from_name():
"""Return ZoneInfo for the request's IANA name, or None if missing/invalid."""
def user_timezone() -> timezone:
"""Return the best known user timezone as a fixed-offset tzinfo."""
offset = get_user_tz_offset()
if offset is None:
name = get_user_tz_name()
if not name:
return None
if name:
try:
from zoneinfo import ZoneInfo
return ZoneInfo(name)
except Exception:
return None
def user_timezone() -> tzinfo:
"""Return the best known user timezone.
A valid IANA name wins over x-tz-offset. The offset is a fixed number and
can disagree with the name (wrong sign, stale client); the name carries DST.
"""
zone = _zoneinfo_from_name()
if zone is not None:
return zone
offset = get_user_tz_offset()
if offset is not None:
return timezone(timedelta(minutes=offset))
pass
return datetime.now().astimezone().tzinfo or timezone.utc
return timezone(timedelta(minutes=offset))
def now_user_local(now_utc: Optional[datetime] = None) -> datetime:
@@ -112,13 +100,14 @@ def _clock_label(dt: datetime) -> str:
def timezone_label(dt: Optional[datetime] = None) -> str:
"""Return a concise display label such as Australia/Brisbane, UTC+10:00."""
offset = get_user_tz_offset()
if offset is None:
if dt is None:
dt = now_user_local()
dt = datetime.now().astimezone()
offset = int((dt.utcoffset() or timedelta()).total_seconds() // 60)
offset_label = f"UTC{format_utc_offset(offset)}"
if _zoneinfo_from_name() is not None:
return f"{get_user_tz_name()}, {offset_label}"
return offset_label
name = get_user_tz_name()
return f"{name}, {offset_label}" if name else offset_label
def current_datetime_prompt(now_utc: Optional[datetime] = None) -> str:
+3 -3
View File
@@ -10,8 +10,8 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
import ragModule from './js/rag.js';
import presetsModule from './js/presets.js';
import searchModule from './js/search.js';
import chatModule from './js/chat.js?v=20260819approvalcontrol1';
import compareModule from './js/compare/index.js?v=20260819approvalcontrol1';
import chatModule from './js/chat.js?v=20260815toolapproval4';
import compareModule from './js/compare/index.js?v=20260723compareicon2';
import documentModule from './js/document.js?v=20260815approvalsave1';
import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js';
@@ -22,7 +22,7 @@ import {
settleSessionHydration
} from './js/startupShell.js';
import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js?v=20260819approvalcontrol1';
import chatRenderer from './js/chatRenderer.js?v=20260815toolapproval4';
import sessionModule from './js/sessions.js';
import memoryModule from './js/memory.js?v=20260722memoryloading1';
import voiceRecorderModule from './js/voiceRecorder.js';
+3 -3
View File
@@ -2572,10 +2572,10 @@
<script type="module" src="/static/js/tts-ai.js"></script>
<script type="module" src="/static/js/document.js?v=20260815approvalsave1"></script>
<script type="module" src="/static/js/gallery.js?v=20260708match1"></script>
<script type="module" src="/static/js/chatRenderer.js?v=20260819approvalcontrol1"></script>
<script type="module" src="/static/js/chatRenderer.js?v=20260815toolapproval4"></script>
<script type="module" src="/static/js/codeRunner.js"></script>
<script type="module" src="/static/js/chatStream.js?v=20260819approvalcontrol1"></script>
<script type="module" src="/static/js/chat.js?v=20260819approvalcontrol1"></script>
<script type="module" src="/static/js/chatStream.js?v=20260815approvalsave1"></script>
<script type="module" src="/static/js/chat.js?v=20260815toolapproval4"></script>
<script type="module" src="/static/js/cookbook.js"></script>
<script src="/static/js/cookbookSchedule.js"></script>
<script type="module" src="/static/js/search-chat.js"></script>
+16 -19
View File
@@ -8,8 +8,8 @@
import Storage from './storage.js';
import uiModule from './ui.js';
import sessionModule from './sessions.js';
import chatRenderer from './chatRenderer.js?v=20260819approvalcontrol1';
import chatStream from './chatStream.js?v=20260819approvalcontrol1';
import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';
import chatStream from './chatStream.js?v=20260815approvalsave1';
import { addAITTSButton } from './tts-ai.js';
import markdownModule from './markdown.js';
import spinnerModule from './spinner.js';
@@ -62,18 +62,20 @@ import { loadPanel } from './panels.js';
let _contextHeaderBound = false;
let _pendingToolApproval = null;
function _submitToolApprovalWhenIdle(approvalId) {
function _submitToolApprovalWhenIdle(approvalId, label) {
if (
!_pendingToolApproval
|| _pendingToolApproval.approval_id !== approvalId
) return;
if (isStreaming || _sendInFlight) {
setTimeout(() => _submitToolApprovalWhenIdle(approvalId), 120);
setTimeout(() => _submitToolApprovalWhenIdle(approvalId, label), 120);
return;
}
const input = document.getElementById('message');
if (input) {
_pendingToolApproval.draft = input.value || '';
input.value = label;
input.dispatchEvent(new Event('input', { bubbles: true }));
}
const sendButton = document.querySelector('.send-btn');
if (sendButton) sendButton.click();
@@ -82,13 +84,16 @@ import { loadPanel } from './panels.js';
document.addEventListener('odysseus:tool-approval', (event) => {
const detail = event && event.detail ? event.detail : {};
const decision = String(detail.decision || '').toLowerCase();
if (!detail.approval_id || !['approve', 'approve_task', 'deny'].includes(decision)) return;
if (!detail.approval_id || !['approve', 'deny'].includes(decision)) return;
_pendingToolApproval = {
approval_id: String(detail.approval_id),
decision,
document_id: String(detail.document_id || ''),
};
_submitToolApprovalWhenIdle(_pendingToolApproval.approval_id);
_submitToolApprovalWhenIdle(
_pendingToolApproval.approval_id,
detail.label || (decision === 'approve' ? 'Allow once' : 'Deny'),
);
});
function _fmtContextNumber(n) {
@@ -1304,10 +1309,10 @@ import { loadPanel } from './panels.js';
}
const el = uiModule.el;
const msg = approvalForSend ? '' : el('message').value;
const msg = el('message').value;
// Allow empty text when a regen carries over the original message's
// attachment ids — a photo-only message still has something to send.
if (!msg.trim() && !approvalForSend && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
if (!msg.trim() && !fileHandlerModule.getPendingCount() && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { _releaseSendFlag(); return; }
// --- Slash commands: execute directly without AI (no session needed) ---
if (!approvalForSend && isCommand(msg.trim())) {
@@ -1585,7 +1590,7 @@ import { loadPanel } from './panels.js';
const userDisplay = _displayOverride || msg;
_displayOverride = null;
const skipBubble = _hideUserBubble || !!approvalForSend;
const skipBubble = _hideUserBubble;
_hideUserBubble = false;
// Auto-recovery counter: carries across a turn's auto-continues, but resets
// when the user genuinely sends a new message (so each task gets a fresh cap).
@@ -1828,7 +1833,7 @@ import { loadPanel } from './panels.js';
if (_inject.suffix) _finalMsgWithInject = _finalMsgWithInject + ' ' + _inject.suffix;
const fd = new FormData();
fd.append('message', approvalForSend ? '' : _finalMsgWithInject);
fd.append('message', _finalMsgWithInject);
fd.append('session', streamSessionId);
if (approvalForSend) {
fd.append('tool_approval_id', approvalForSend.approval_id);
@@ -2868,7 +2873,7 @@ import { loadPanel } from './panels.js';
if (spinner && spinner.element) spinner.destroy();
break;
}
if (json.delta || json.type === 'agent_prep' || json.type === 'tool_approval_resolved' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
if (json.delta || json.type === 'agent_prep' || json.type === 'generated_image' || json.type === 'tool_start' || json.type === 'tool_output' || json.type === 'tool_progress' || json.type === 'agent_step' || json.type === 'loop_breaker_triggered' || json.type === 'intent_nudge_exhausted' || json.type === 'doc_stream_open' || json.type === 'doc_stream_delta' || json.type === 'research_progress') {
clearResponseTimeout();
clearProcessingProbe();
clearFirstTokenWaitTimers();
@@ -2885,14 +2890,6 @@ import { loadPanel } from './panels.js';
}
continue;
}
if (json.type === 'tool_approval_resolved') {
_cancelThinkingTimer();
_removeThinkingSpinner();
if (spinner && spinner.element) spinner.destroy();
if (!_isBg && roundHolder && roundHolder !== holder) roundHolder.remove();
if (!_isBg && holder) holder.remove();
continue;
}
if (json.delta) {
_cancelThinkingTimer();
_removeThinkingSpinner();
+9 -69
View File
@@ -2327,42 +2327,6 @@ export function removeAskUserCards(root) {
scope.querySelectorAll('.ask-user-card').forEach((node) => node.remove());
}
// While a choice card is visible, let plain 1–3 activate the corresponding
// rendered option. Reuse the option's click path so the question keeps its
// existing submission semantics. Tool approval cards are excluded: that card
// exists to make consent deliberate after untrusted context influenced the
// run, and its first option is the widest grant, so a stray digit must not
// answer it.
function _handleAskUserShortcut(event) {
if (
event.defaultPrevented
|| event.repeat
|| event.isComposing
|| event.ctrlKey
|| event.altKey
|| event.metaKey
|| event.shiftKey
) return;
if (!/^[1-3]$/.test(event.key)) return;
const target = event.target;
if (target?.closest?.('input, textarea, select, [contenteditable="true"]')) return;
const focusedCard = document.activeElement?.closest?.('.ask-user-card') || null;
const mainCard = document.querySelector('#chat-history .ask-user-card');
const compareCards = document.querySelectorAll('.compare-pane .ask-user-card');
const card = focusedCard || mainCard || (compareCards.length === 1 ? compareCards[0] : null);
if (!card) return;
if (card.dataset.askUserKind === 'tool_approval') return;
const option = card.querySelectorAll('.ask-user-option')[Number(event.key) - 1];
if (!option || option.disabled) return;
event.preventDefault();
option.click();
}
document.addEventListener('keydown', _handleAskUserShortcut);
/**
* Render an ask_user payload as a durable choice card.
*
@@ -2372,15 +2336,11 @@ document.addEventListener('keydown', _handleAskUserShortcut);
*/
export function renderAskUserCard(payload, options) {
const aq = payload || {};
if (aq.resolved) return null;
const opts = Array.isArray(aq.options) ? aq.options : [];
const renderOptions = options || {};
const chatBox = renderOptions.root || document.getElementById('chat-history');
const onSubmit = typeof renderOptions.onSubmit === 'function'
? renderOptions.onSubmit
: null;
const chatBox = document.getElementById('chat-history');
if (!chatBox || !aq.question || opts.length < 2) return null;
const renderOptions = options || {};
removeAskUserCards(chatBox);
const card = document.createElement('div');
@@ -2389,7 +2349,6 @@ export function renderAskUserCard(payload, options) {
card.tabIndex = -1;
const multi = !!aq.multi;
const isToolApproval = aq.kind === 'tool_approval' && !!aq.approval_id;
card.dataset.askUserKind = isToolApproval ? 'tool_approval' : 'question';
const emojiText = (value) => svgifyEmoji(uiModule.esc(String(value)));
const head = document.createElement('div');
@@ -2398,6 +2357,7 @@ export function renderAskUserCard(payload, options) {
closeBtn.type = 'button';
closeBtn.className = 'modal-close ask-user-close';
closeBtn.setAttribute('aria-label', 'Dismiss question');
closeBtn.textContent = '×';
closeBtn.addEventListener('click', () => {
card.remove();
const input = uiModule.el('message');
@@ -2440,17 +2400,6 @@ export function renderAskUserCard(payload, options) {
const send = (text) => {
if (!text) return;
if (onSubmit) {
const accepted = onSubmit({
kind: 'answer',
text,
label: text,
payload: aq,
card,
});
if (accepted !== false) card.remove();
return;
}
card.remove();
const input = uiModule.el('message');
if (input) input.value = text;
@@ -2484,26 +2433,17 @@ export function renderAskUserCard(payload, options) {
row.type = 'button';
row.addEventListener('click', () => {
if (isToolApproval) {
const detail = {
card.remove();
document.dispatchEvent(new CustomEvent('odysseus:tool-approval', {
detail: {
approval_id: aq.approval_id,
decision: String((opt && opt.value) || '').toLowerCase(),
label,
document_id: aq.action && aq.action.document_id
? String(aq.action.document_id)
: '',
};
if (onSubmit) {
const accepted = onSubmit({
kind: 'tool_approval',
...detail,
payload: aq,
card,
});
if (accepted !== false) card.remove();
} else {
card.remove();
document.dispatchEvent(new CustomEvent('odysseus:tool-approval', { detail }));
}
},
}));
} else {
send(label);
}
@@ -2688,7 +2628,7 @@ export function addMessage(role, content, modelName, metadata) {
box.appendChild(threadWrap);
}
for (const ev of roundTools) {
if (ev.ask_user && !ev.ask_user.resolved) pendingAskUser = ev.ask_user;
if (ev.ask_user) pendingAskUser = ev.ask_user;
const ok = (ev.exit_code === 0 || ev.exit_code == null);
let outHtml = '';
if (ev.output && ev.output.trim()) {
-29
View File
@@ -9,35 +9,6 @@ import markdownModule from './markdown.js';
import sessionModule from './sessions.js';
import documentModule from './document.js?v=20260815approvalsave1';
// Tool approvals are control-plane submits for the current chat. chat.js
// deliberately leaves the composer untouched, then programmatically clicks the
// shared send button after it records the sealed approval id/decision. That
// button is polymorphic: with an empty composer it can mean New chat or Record
// voice instead of Send. Intercept only the programmatic approval click and
// route it through the form submit path, which already reaches chat.js directly.
document.addEventListener('odysseus:tool-approval', () => {
const sendButton = document.querySelector('.send-btn');
const chatForm = document.getElementById('chat-form');
if (!sendButton || !chatForm) return;
const interceptApprovalClick = (event) => {
// A real user click must retain the normal send/new-chat/STT behavior.
if (event.isTrusted) return;
sendButton.removeEventListener('click', interceptApprovalClick, true);
event.preventDefault();
event.stopImmediatePropagation();
if (chatForm.requestSubmit) chatForm.requestSubmit();
else chatForm.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
};
sendButton.addEventListener('click', interceptApprovalClick, true);
// Fail-safe cleanup if the approval continuation never reaches its deferred
// synthetic click (for example because the surrounding view is torn down).
setTimeout(() => {
sendButton.removeEventListener('click', interceptApprovalClick, true);
}, 60000);
}, true);
/**
* Handle a ui_control SSE event — AI-driven UI manipulation.
* Extracted from the duplicated ui_control + tool_output.ui_event handlers.
+7 -12
View File
@@ -21,7 +21,7 @@ import { EVAL_PROMPTS, WAVE_FRAMES,
import { fetchModels, _persistSelections, _modelDisplayNames, getExcludedModels, setExcludedModels } from './models.js';
import { showModelSelector, disableToolToggles, restoreToolToggles, _syncToolbarIndicator } from './selector.js?v=20260723compareicon2';
import { _checkUnprobed, _clearProbeWaves } from './probe.js';
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js?v=20260819approvalcontrol1';
import { streamToPane, _renderSearchResults, _runSynthForPane, _formatMs, registerStreamActions } from './stream.js';
import {
stopAll, stopPane, rerollPane, shufflePanePositions, resetCompare,
_addPane, _removePane, toggleExpandPane, togglePanePreview, copyPaneResponse,
@@ -1006,16 +1006,11 @@ async function _executeCompare(message) {
console.error('Compare error:', err);
if (uiModule) uiModule.showError('Compare failed: ' + err.message);
} finally {
// A pane may have started its own ask_user/approval continuation while the
// original all-pane Promise was settling. Keep Compare busy until every
// pane-owned controller is gone instead of exposing a second broadcast send.
const compareStillStreaming = state._abortControllers.some(Boolean);
state._streaming = compareStillStreaming;
_setSendBtn(compareStillStreaming ? 'stop' : 'send');
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
button.disabled = compareStillStreaming;
button.style.opacity = compareStillStreaming ? '0.25' : '0.7';
button.style.pointerEvents = compareStillStreaming ? 'none' : '';
state._streaming = false;
_setSendBtn('send');
// Re-enable header buttons
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach(b => {
b.disabled = false; b.style.opacity = '0.7'; b.style.pointerEvents = '';
});
}
}
@@ -1519,7 +1514,7 @@ async function showShufflePoolEditor() {
// ────────────────────────────────────────────────────────────────────────────
registerCompareActions({ stopAll, resetCompare });
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });
registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml });
registerPaneActions({ setSendBtn: _setSendBtn, deactivate, streamToPane, renderSearchResults: _renderSearchResults, fetchModels });
// ────────────────────────────────────────────────────────────────────────────
+5 -188
View File
@@ -1,7 +1,7 @@
// compare/stream.js — SSE streaming to panes
import state from './state.js';
import { addFinishBadge } from './vote.js';
import { getModelCost, renderAskUserCard, safeDisplayImageSrc } from '../chatRenderer.js?v=20260819approvalcontrol1';
import { getModelCost, safeDisplayImageSrc } from '../chatRenderer.js';
import markdownModule from '../markdown.js';
import spinnerModule from '../spinner.js';
import uiModule from '../ui.js';
@@ -24,157 +24,11 @@ function _safeHttpHref(raw) {
// ── Lazy-registered functions from compare.js (avoids circular deps) ──
let _rerollPane = null;
let _autoPreviewHtml = null;
let _setSendBtn = null;
/** Register external functions that live in compare.js. */
function registerStreamActions({ rerollPane, autoPreviewHtml, setSendBtn }) {
function registerStreamActions({ rerollPane, autoPreviewHtml }) {
_rerollPane = rerollPane;
_autoPreviewHtml = autoPreviewHtml;
_setSendBtn = setSendBtn;
}
function _paneSessionIsCurrent(paneIdx, sessionId) {
return Boolean(
state.isActive
&& state._paneSessionIds[paneIdx] === sessionId
&& document.getElementById('cmp-history-' + paneIdx)
);
}
function _setCompareBusy(active) {
state._streaming = Boolean(active);
if (_setSendBtn) _setSendBtn(active ? 'stop' : 'send');
document.querySelectorAll('#compare-shuffle-btn, #compare-check-btn, #compare-add-btn').forEach((button) => {
button.disabled = Boolean(active);
button.style.opacity = active ? '0.25' : '0.7';
button.style.pointerEvents = active ? 'none' : '';
});
}
function _syncCompareBusyFromPanes() {
_setCompareBusy((state._abortControllers || []).some(Boolean));
}
function _appendPaneMessage(hist, role, text) {
const message = document.createElement('div');
message.className = 'msg ' + (role === 'user' ? 'msg-user' : 'msg-ai');
const roleEl = document.createElement('div');
roleEl.className = 'role';
roleEl.textContent = role === 'user' ? 'You' : 'AI';
const body = document.createElement('div');
body.className = 'body';
body.textContent = text || '';
message.appendChild(roleEl);
message.appendChild(body);
hist.appendChild(message);
return message;
}
function _createPaneContinuationMessage(hist) {
const message = _appendPaneMessage(hist, 'assistant', '');
const body = message.querySelector('.body');
if (spinnerModule) {
const spinner = spinnerModule.create('Continuing...', 'right');
body.appendChild(spinner.createElement());
spinner.start();
message._spinner = spinner;
}
return message;
}
function _restorePaneAskUserCard(paneIdx, sessionId, submission, originController) {
const hist = document.getElementById('cmp-history-' + paneIdx);
const restored = _renderPaneAskUserCard(
paneIdx,
sessionId,
submission.payload || {},
hist,
null,
originController,
);
if (uiModule) {
uiModule.showError(
restored
? 'This pane is still streaming — choose again once it settles.'
: 'Compare pane is still streaming; the choice was not sent.',
);
}
return restored;
}
function _resumePaneChoiceWhenIdle(paneIdx, sessionId, originController, submission) {
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return false;
const startedAt = Date.now();
const resume = () => {
if (!_paneSessionIsCurrent(paneIdx, sessionId)) return;
const activeController = state._abortControllers[paneIdx];
if (activeController === originController) {
if (Date.now() - startedAt < 10000) {
setTimeout(resume, 25);
return;
}
// The originating stream never released the pane. The card was already
// removed when the choice was accepted, so put it back rather than
// swallowing a decision the user made.
_restorePaneAskUserCard(paneIdx, sessionId, submission, originController);
return;
}
// A reroll/model replacement already owns this pane. Never send the stale
// choice into that replacement stream or session UI.
if (activeController) return;
const hist = document.getElementById('cmp-history-' + paneIdx);
if (!hist) return;
hist.querySelectorAll('.ask-user-card').forEach((card) => card.remove());
const isApproval = submission.kind === 'tool_approval';
const message = isApproval ? '' : String(submission.text || submission.label || '');
if (!isApproval) _appendPaneMessage(hist, 'user', message);
const aiMessage = _createPaneContinuationMessage(hist);
hist.scrollTop = hist.scrollHeight;
const resumeOptions = { skipBadge: true };
if (isApproval) {
resumeOptions.toolApproval = {
approval_id: String(submission.approval_id || ''),
decision: String(submission.decision || '').toLowerCase(),
};
}
_setCompareBusy(true);
streamToPane(paneIdx, sessionId, message, aiMessage, resumeOptions)
.catch((error) => {
console.error('Compare pane continuation failed:', error);
if (uiModule) uiModule.showError('Compare continuation failed: ' + error.message);
})
.finally(_syncCompareBusyFromPanes);
};
setTimeout(resume, 0);
return true;
}
function _renderPaneAskUserCard(paneIdx, sessionId, payload, hist, aiMsgEl, originController) {
if (!hist || !hist.isConnected || !_paneSessionIsCurrent(paneIdx, sessionId)) return null;
if (aiMsgEl && aiMsgEl._spinner) {
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
aiMsgEl._spinner = null;
}
const card = renderAskUserCard(payload, {
root: hist,
onSubmit: (submission) => _resumePaneChoiceWhenIdle(
paneIdx,
sessionId,
originController,
submission,
),
});
if (card) {
card.dataset.comparePane = String(paneIdx);
card.dataset.compareSession = String(sessionId);
}
return card;
}
/** Format milliseconds as human-readable duration (e.g. "120ms", "1.23s", "4.5s"). */
@@ -310,7 +164,6 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
let metrics = null;
let timedOut = false;
let streamOk = false;
let awaitingChoice = false;
let currentToolBlock = null; // track active agent tool block
// Idle timeout — abort only if no data is received for this many seconds.
// Long generations (SVG, big code) are fine as long as the stream stays
@@ -366,10 +219,6 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
const fd = new FormData();
fd.append('message', message);
fd.append('session', sessionId);
if (opts.toolApproval) {
fd.append('tool_approval_id', opts.toolApproval.approval_id || '');
fd.append('tool_approval_decision', opts.toolApproval.decision || '');
}
// Compare mode determines what tools/features are enabled
const isAgent = state._compareMode === 'agent';
@@ -473,36 +322,6 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}
}
// ── Pane-local question / approval selector ──
} else if (json.type === 'ask_user') {
awaitingChoice = true;
_renderPaneAskUserCard(
paneIdx,
sessionId,
json.data || {},
hist,
aiMsgEl,
ac,
);
if (hist) hist.scrollTop = hist.scrollHeight;
// Deny ends as a tiny resolution-only stream, so replace the
// continuation spinner with an explicit pane-local result.
} else if (json.type === 'tool_approval_resolved') {
if (aiMsgEl._spinner) {
if (aiMsgEl._spinner.element) aiMsgEl._spinner.destroy();
aiMsgEl._spinner = null;
}
accumulated = json.decision === 'deny' ? 'Denied.' : 'Approval recorded.';
let target = aiMsgEl._textEl;
if (!target) {
target = document.createElement('div');
target.className = 'compare-text-content';
aiBody.appendChild(target);
aiMsgEl._textEl = target;
}
target.textContent = accumulated;
// ── Tool start (bash, web search agent tool) ──
} else if (json.type === 'tool_start') {
// Finalize any accumulated text before the tool block
@@ -821,21 +640,19 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
// TTFT removed from the header per user request — just show total time.
_timerEl.textContent = _formatMs(_totalMs);
}
if (state._abortControllers[paneIdx] === ac) {
state._abortControllers[paneIdx] = null;
}
// Hide stop button, show response action buttons
const _paneElFinal = document.querySelector(`.compare-pane[data-pane="${paneIdx}"]`);
if (_paneElFinal) {
const _stopBtnFinal = _paneElFinal.querySelector('.pane-stop-btn');
if (_stopBtnFinal) _stopBtnFinal.style.display = 'none';
if (!awaitingChoice && accumulated.trim()) {
if (accumulated.trim()) {
_paneElFinal.querySelectorAll('.pane-needs-response').forEach(b => b.style.display = '');
}
}
state._paneMetrics[paneIdx] = metrics;
state._paneElapsed[paneIdx] = _totalMs;
if (!opts.skipBadge && !awaitingChoice) {
if (!opts.skipBadge) {
if (streamOk) {
state._finishOrder++;
if (state._parallel) {
@@ -865,7 +682,7 @@ async function streamToPane(paneIdx, sessionId, message, aiMsgEl, opts) {
}
}
// Auto-grade against expected answer — stamps ✓ or ✗ on the pane header.
if (streamOk && !awaitingChoice && state._expectedAnswer) {
if (streamOk && state._expectedAnswer) {
_stampGradeBadge(paneIdx, accumulated, state._expectedAnswer);
}
// Show copy/reroll buttons now that response exists
+4 -45
View File
@@ -123,7 +123,7 @@ def test_atomic_write_json_concurrent_writers_do_not_collide(tmp_path):
# ---------------------------------------------------------------------------
# atomic_write_json — failure paths
# atomic_write_json — failure path: target preserved on serialization error.
# ---------------------------------------------------------------------------
def test_atomic_write_json_preserves_target_when_serialization_fails(tmp_path):
target = tmp_path / "data.json"
@@ -136,26 +136,6 @@ def test_atomic_write_json_preserves_target_when_serialization_fails(tmp_path):
atomic_write_json(str(target), {"bad": {1, 2, 3}})
assert target.read_text(encoding="utf-8") == before
# Temp file should be cleaned up
assert _tmp_siblings(tmp_path, "data.json") == []
def test_atomic_write_json_preserves_target_when_replace_fails(tmp_path, monkeypatch):
target = tmp_path / "data.json"
atomic_write_json(str(target), {"existing": "value"})
before = target.read_text(encoding="utf-8")
def boom(src, dst):
raise PermissionError("replace failed")
monkeypatch.setattr(atomic_io.os, "replace", boom)
with pytest.raises(PermissionError, match="replace failed"):
atomic_write_json(str(target), {"new": "content"})
assert target.read_text(encoding="utf-8") == before
# Temp file should be cleaned up
assert _tmp_siblings(tmp_path, "data.json") == []
# ---------------------------------------------------------------------------
@@ -207,7 +187,7 @@ def test_atomic_write_text_rejects_non_string_before_tmp_file(tmp_path):
# ---------------------------------------------------------------------------
# atomic_write_text — failure paths
# atomic_write_text — failure path: target preserved when replace fails.
# ---------------------------------------------------------------------------
def test_atomic_write_text_preserves_target_when_replace_fails(tmp_path, monkeypatch):
target = tmp_path / "note.txt"
@@ -215,32 +195,11 @@ def test_atomic_write_text_preserves_target_when_replace_fails(tmp_path, monkeyp
before = target.read_text(encoding="utf-8")
def boom(src, dst):
raise PermissionError("replace failed")
raise OSError("replace failed")
monkeypatch.setattr(atomic_io.os, "replace", boom)
with pytest.raises(PermissionError, match="replace failed"):
with pytest.raises(OSError):
atomic_write_text(str(target), "new content that never lands")
assert target.read_text(encoding="utf-8") == before
# Temp file should be cleaned up
assert _tmp_siblings(tmp_path, "note.txt") == []
def test_cleanup_error_swallows_and_preserves_original_exception(tmp_path, monkeypatch):
target = tmp_path / "note.txt"
atomic_write_text(str(target), "original content")
def replace_boom(src, dst):
raise PermissionError("replace failed")
def unlink_boom(path):
raise OSError("unlink failed")
monkeypatch.setattr(atomic_io.os, "replace", replace_boom)
monkeypatch.setattr(atomic_io.os, "unlink", unlink_boom)
# If BOTH the replace fails AND the cleanup unlink fails,
# the original replace error should surface, completely swallowing the unlink error.
with pytest.raises(PermissionError, match="replace failed"):
atomic_write_text(str(target), "new content")
-61
View File
@@ -1,61 +0,0 @@
from pathlib import Path
def test_compare_renders_ask_user_in_the_originating_pane():
root = Path(__file__).resolve().parents[1]
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
assert "renderAskUserCard" in stream
assert "} else if (json.type === 'ask_user') {" in stream
assert "root: hist" in stream
assert "state._paneSessionIds[paneIdx] === sessionId" in stream
assert "streamToPane(paneIdx, sessionId, message, aiMessage, resumeOptions)" in stream
assert "handleCompareSubmit" not in stream
def test_compare_submits_approval_only_to_the_pane_session():
root = Path(__file__).resolve().parents[1]
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
assert "fd.append('tool_approval_id', opts.toolApproval.approval_id || '');" in stream
assert "fd.append('tool_approval_decision', opts.toolApproval.decision || '');" in stream
assert "const isApproval = submission.kind === 'tool_approval';" in stream
assert "const message = isApproval ? ''" in stream
assert "if (!isApproval) _appendPaneMessage(hist, 'user', message);" in stream
assert "json.type === 'tool_approval_resolved'" in stream
assert "json.decision === 'deny' ? 'Denied.'" in stream
def test_compare_continuation_does_not_lose_or_replace_pane_ownership():
root = Path(__file__).resolve().parents[1]
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
index = (root / "static/js/compare/index.js").read_text(encoding="utf-8")
assert "if (state._abortControllers[paneIdx] === ac)" in stream
assert "if (activeController === originController)" in stream
assert "if (activeController) return;" in stream
assert "registerStreamActions({ rerollPane, autoPreviewHtml: _autoPreviewHtml, setSendBtn: _setSendBtn });" in index
assert "const compareStillStreaming = state._abortControllers.some(Boolean);" in index
assert "_setSendBtn(compareStillStreaming ? 'stop' : 'send');" in index
def test_compare_restores_the_card_instead_of_dropping_a_timed_out_choice():
"""The card is removed the moment a choice is accepted.
If the originating stream still owns the pane when the resume deadline
passes, the decision has nowhere to go — so the card has to come back
rather than the click vanishing silently.
"""
root = Path(__file__).resolve().parents[1]
stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
assert "function _restorePaneAskUserCard(" in stream
assert "_restorePaneAskUserCard(paneIdx, sessionId, submission, originController);" in stream
assert "submission.payload || {}" in stream
start = stream.index("function _resumePaneChoiceWhenIdle(")
end = stream.index("function _renderPaneAskUserCard(", start)
resume = stream[start:end]
# The deadline must not fall through to a bare return any more.
assert "if (Date.now() - startedAt < 10000) setTimeout(resume, 25);" not in resume
+4 -6
View File
@@ -344,7 +344,7 @@ async def test_chat_stream_approval_restores_exact_shell_turn_toggle(monkeypatch
@pytest.mark.asyncio
async def test_chat_stream_denial_returns_control_resolution(monkeypatch):
async def test_chat_stream_denial_keeps_originating_run_tainted(monkeypatch):
from src.tool_capabilities import capabilities_for_action
captured = {}
@@ -368,13 +368,11 @@ async def test_chat_stream_denial_returns_control_resolution(monkeypatch):
)
response = await endpoint(request)
chunks = [chunk async for chunk in response.body_iterator]
async for _ in response.body_iterator:
pass
event = json.loads(chunks[0][len("data: "):])
assert event == {"type": "tool_approval_resolved", "decision": "deny"}
assert chunks[-1] == "data: [DONE]\n\n"
assert "agent" not in captured
assert "exact_approval" not in captured
assert captured["agent_external_untrusted_context_seen"] is True
assert chat_routes.tool_approval_store.peek(pending.approval_id) is None
-140
View File
@@ -1,140 +0,0 @@
"""Pin the mlx_image_server caller-chosen-model + DNS-rebinding regressions.
Background: scripts/mlx_image_server.py used to resolve the model per request
(``req.model or _args.model``) instead of serving the model the process was
launched with. ``_is_hidream`` is a substring test and ``_snapshot_path``
accepts either a local directory or a Hugging Face repo id, so a caller could
name any directory / repo and the HiDream branch would then run
``<model>/scripts/hidream_o1/generate_hidream_o1_mlx.py`` under
``sys.executable``. The server has no auth, and the cookbook binds it to
``0.0.0.0`` whenever it is serving to a remote host, so that was reachable
code execution.
The fix pins both request paths to ``_args.model``, matching
scripts/diffusion_server.py.
"""
import argparse
import importlib.util
import sys
import types
from pathlib import Path
import pytest
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "mlx_image_server.py"
_BASE_URL = "http://127.0.0.1"
def _load_module():
"""Fresh import of the server module. Unlike diffusion_server it pulls in no
heavy runtime (mlx / torch imports all live inside the request handlers), so
the real module is imported rather than AST-extracted."""
spec = importlib.util.spec_from_file_location("mlx_image_server_under_test", _SCRIPT)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture
def server(monkeypatch):
"""Server module launched with a pinned, non-HiDream model."""
module = _load_module()
module._args = argparse.Namespace(
model="mlx-community/pinned-model",
host="127.0.0.1",
port=8100,
steps=0,
width=512,
height=512,
base_model="",
lora_style="",
lora_paths=[],
lora_scales=[],
vlm_model="",
)
return module
def _client(module):
from fastapi.testclient import TestClient
return TestClient(module.app, base_url=_BASE_URL)
def _plant_hidream_model_dir(tmp_path: Path) -> tuple[Path, Path]:
"""A directory that satisfies _is_hidream() and carries the script the
HiDream branch executes. The script writes a marker so the test can tell
whether it ran."""
model_dir = tmp_path / "hidream-planted"
generator = model_dir / "scripts" / "hidream_o1"
generator.mkdir(parents=True)
marker = model_dir / "executed.txt"
(generator / "generate_hidream_o1_mlx.py").write_text(
f"open({str(marker)!r}, 'w').write('ran')\n", encoding="utf-8"
)
return model_dir, marker
def test_generate_does_not_run_code_from_a_caller_named_model_dir(server, tmp_path):
"""The regression: naming a local directory as the model must not execute
the generator script inside it."""
model_dir, marker = _plant_hidream_model_dir(tmp_path)
_client(server).post(
"/v1/images/generations",
json={"model": str(model_dir), "prompt": "x", "size": "64x64"},
)
assert not marker.exists(), (
"code inside the caller-named model directory ran; the request model "
"must not select the generator"
)
def test_generate_does_not_fetch_a_caller_named_repo(server, monkeypatch):
"""The remote half of the same defect: the caller's string must never reach
the Hugging Face downloader."""
downloaded = []
stub = types.ModuleType("huggingface_hub")
stub.snapshot_download = lambda repo: downloaded.append(repo)
monkeypatch.setitem(sys.modules, "huggingface_hub", stub)
_client(server).post(
"/v1/images/generations",
json={"model": "attacker-account/hidream-anything", "prompt": "x"},
)
assert "attacker-account/hidream-anything" not in downloaded
def test_edits_does_not_run_code_from_a_caller_named_model_dir(server, tmp_path):
"""/v1/images/edits resolved the model the same way and must be pinned too.
A "lama" name reaches the inpaint bridge, so the caller's model string is
what picks the branch here."""
model_dir = tmp_path / "lama-planted"
model_dir.mkdir()
called = []
server._run_inpaint_bridge = lambda *a, **kw: called.append(a)
server._run_ddcolor_bridge = lambda *a, **kw: called.append(a)
resp = _client(server).post(
"/v1/images/edits",
data={"model": str(model_dir), "prompt": "x"},
files={"image": ("i.png", b"not-a-real-png", "image/png")},
)
assert not called, "caller-supplied model selected the edit branch"
assert resp.status_code == 422, "pinned non-edit model should be refused"
def test_pinned_hidream_model_is_still_served(server, tmp_path, monkeypatch):
"""Behaviour preservation: pinning must not break a server that was actually
launched with a HiDream model."""
model_dir, marker = _plant_hidream_model_dir(tmp_path)
server._args.model = str(model_dir)
_client(server).post("/v1/images/generations", json={"model": "", "prompt": "x"})
assert marker.exists(), "the model this server was launched with must still run"
@@ -1,103 +0,0 @@
from pathlib import Path
def test_tool_approval_bypasses_polymorphic_send_button_actions():
root = Path(__file__).resolve().parents[1]
chat = (root / "static/js/chat.js").read_text(encoding="utf-8")
stream = (root / "static/js/chatStream.js").read_text(encoding="utf-8")
# chat.js still defers the sealed approval through a synthetic button click.
assert "if (sendButton) sendButton.click();" in chat
# The capture listener must intercept only that synthetic click and route it
# through the chat form submit path, before app.js can reinterpret an empty
# composer as New chat or Record voice.
assert "if (event.isTrusted) return;" in stream
assert "event.stopImmediatePropagation();" in stream
assert "chatForm.requestSubmit()" in stream
assert "sendButton.dataset.mode = ''" not in stream
def test_ask_user_close_button_uses_one_css_glyph():
root = Path(__file__).resolve().parents[1]
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
styles = (root / "static/style.css").read_text(encoding="utf-8")
assert "closeBtn.className = 'modal-close ask-user-close';" in renderer
assert "closeBtn.setAttribute('aria-label', 'Dismiss question');" in renderer
assert "closeBtn.textContent = '×';" not in renderer
assert ".modal-close::before" in styles
def test_ask_user_number_shortcuts_reuse_option_click_path():
root = Path(__file__).resolve().parents[1]
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
start = renderer.index("function _handleAskUserShortcut(event)")
end = renderer.index("document.addEventListener('keydown', _handleAskUserShortcut);", start)
shortcut = renderer[start:end]
assert "if (!/^[1-3]$/.test(event.key)) return;" in shortcut
assert "event.repeat" in shortcut
assert "event.ctrlKey" in shortcut
assert "event.altKey" in shortcut
assert "event.metaKey" in shortcut
assert "event.shiftKey" in shortcut
assert "input, textarea, select, [contenteditable=\"true\"]" in shortcut
assert "card.querySelectorAll('.ask-user-option')[Number(event.key) - 1]" in shortcut
assert "event.preventDefault();" in shortcut
assert "option.click();" in shortcut
def test_digit_shortcuts_never_answer_a_tool_approval_card():
"""A stray digit must not grant a scope the user did not deliberately pick."""
root = Path(__file__).resolve().parents[1]
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
start = renderer.index("function _handleAskUserShortcut(event)")
end = renderer.index("document.addEventListener('keydown', _handleAskUserShortcut);", start)
shortcut = renderer[start:end]
assert "if (card.dataset.askUserKind === 'tool_approval') return;" in shortcut
# The renderer has to label the card for that guard to ever fire.
assert (
"card.dataset.askUserKind = isToolApproval ? 'tool_approval' : 'question';"
in renderer
)
def test_ask_user_renderer_accepts_scoped_root_and_submit_callback():
root = Path(__file__).resolve().parents[1]
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
assert "const chatBox = renderOptions.root || document.getElementById('chat-history');" in renderer
assert "const onSubmit = typeof renderOptions.onSubmit === 'function'" in renderer
assert "kind: 'answer'" in renderer
assert "kind: 'tool_approval'" in renderer
assert "if (accepted !== false) card.remove();" in renderer
assert "document.dispatchEvent(new CustomEvent('odysseus:tool-approval', { detail }))" in renderer
def test_every_changed_approval_module_is_cache_busted_together():
"""A stale module here silently reinterprets the approval click.
chat.js leaves the composer empty and clicks the polymorphic send button,
so a browser that pairs the new chat.js with a cached chatStream.js has no
interceptor and lands on the New chat branch instead. The same holds for
the compare pane modules, which chatRenderer now shares a keydown listener
with.
"""
root = Path(__file__).resolve().parents[1]
version = "20260819approvalcontrol1"
index = (root / "static/index.html").read_text(encoding="utf-8")
app = (root / "static/app.js").read_text(encoding="utf-8")
chat = (root / "static/js/chat.js").read_text(encoding="utf-8")
compare_index = (root / "static/js/compare/index.js").read_text(encoding="utf-8")
compare_stream = (root / "static/js/compare/stream.js").read_text(encoding="utf-8")
assert f"chatStream.js?v={version}" in index
assert f"chatStream.js?v={version}" in chat
assert f"compare/index.js?v={version}" in app
assert f"stream.js?v={version}" in compare_index
# One chatRenderer instance, so the ask_user keydown listener binds once.
assert f"chatRenderer.js?v={version}" in compare_stream
@@ -1,89 +0,0 @@
"""Callers with no resumable chat keep the original one-use approval scope.
The chat card reuses the wire value ``approve`` for chat-session scope, so any
caller that still sends ``approve`` meaning "once" has to say so explicitly or
it silently inherits a run-long gate bypass.
"""
from pathlib import Path
from src.tool_approval_scopes import ToolApprovalScope
from src.tool_approvals import ToolApprovalStore
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
def _pending(store: ToolApprovalStore, *, session_id=""):
content = "printf exact"
return store.create(
owner="Alice",
session_id=session_id,
origin_run_id="run-1",
tool_name="bash",
content=content,
workspace=None,
external_untrusted_context_seen=True,
capabilities=capabilities_for_action("bash", content),
)
def test_single_action_grant_leaves_the_gate_armed_behind_the_sealed_action():
store = ToolApprovalStore()
pending = _pending(store)
grant = store.consume(
pending.approval_id,
decision="approve",
owner="alice",
session_id=None,
allow_continuation=False,
)
assert grant is not None
assert grant.scope is ToolApprovalScope.SINGLE_ACTION
assert grant.allow_remaining_actions is False
assert grant.grants_chat_session is False
resumed = ToolRunSecurityContext(
external_untrusted_context_seen=True,
approval_gate_bypassed=grant.allow_remaining_actions,
)
assert resumed.decision_for("bash").allowed is False
def test_chat_callers_still_get_the_continuation_scope_they_asked_for():
store = ToolApprovalStore()
pending = _pending(store, session_id="session-1")
grant = store.consume(
pending.approval_id,
decision="approve_task",
owner="alice",
session_id="session-1",
)
assert grant is not None
assert grant.scope is ToolApprovalScope.TASK
assert grant.allow_remaining_actions is True
def test_deny_is_unaffected_by_the_single_action_flag():
store = ToolApprovalStore()
pending = _pending(store)
assert store.consume(
pending.approval_id,
decision="deny",
owner="alice",
session_id=None,
allow_continuation=False,
) is None
assert store.peek(pending.approval_id) is None
def test_skill_test_approval_route_opts_out_of_continuation():
root = Path(__file__).resolve().parents[1]
skills = (root / "routes/skills_routes.py").read_text(encoding="utf-8")
approve_call = skills.index("exact_approval = tool_approval_store.consume(")
end = skills.index(")", skills.index("allow_continuation", approve_call))
assert "allow_continuation=False" in skills[approve_call:end]
-351
View File
@@ -1,351 +0,0 @@
"""Task- and chat-scoped approval continuation coverage for issue #6112."""
import asyncio
import json
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from core.models import ChatMessage, Session
from src.tool_approval_scopes import (
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
ToolApprovalScope,
)
from src.tool_approvals import ExactToolApproval, ToolApprovalStore
from src.tool_capabilities import ToolRunSecurityContext, capabilities_for_action
def _pending(
store: ToolApprovalStore,
*,
selected_tools=None,
continuation_query="inspect the project using memory and skills",
):
content = "printf exact"
return store.create(
owner="Alice",
session_id="session-1",
origin_run_id="run-1",
tool_name="bash",
content=content,
workspace=None,
external_untrusted_context_seen=True,
selected_tools=selected_tools,
continuation_query=continuation_query,
capabilities=capabilities_for_action("bash", content),
)
def test_card_offers_task_chat_session_and_deny_without_leaking_private_state():
pending = _pending(
ToolApprovalStore(),
selected_tools=["manage_skills", "bash", "manage_skills"],
)
payload = pending.public_payload()
assert payload["session_id"] == "session-1"
assert [option["value"] for option in payload["options"]] == [
"approve_task",
"approve",
"deny",
]
assert [option["label"] for option in payload["options"]] == [
"Allow for this task",
"Allow for this chat session",
"Deny",
]
serialized = json.dumps(payload, sort_keys=True)
assert "Allow once" not in serialized
assert "selected_tools" not in serialized
assert "continuation_query" not in serialized
assert "manage_skills" not in serialized
assert "inspect the project" not in serialized
def test_allow_for_task_bypasses_only_the_resumed_run_gate():
store = ToolApprovalStore()
pending = _pending(store, selected_tools=["bash", "manage_skills"])
grant = store.consume(
pending.approval_id,
decision="approve_task",
owner="alice",
session_id="session-1",
)
assert grant is not None
assert grant.scope is ToolApprovalScope.TASK
assert grant.allow_remaining_actions is True
assert grant.grants_chat_session is False
assert grant.pending.continuation_query == (
"inspect the project using memory and skills"
)
resumed = ToolRunSecurityContext(
external_untrusted_context_seen=True,
approval_gate_bypassed=grant.allow_remaining_actions,
)
assert resumed.decision_for("bash").allowed is True
# A new ordinary user turn constructs a fresh context and asks again.
fresh = ToolRunSecurityContext(external_untrusted_context_seen=True)
assert fresh.decision_for("bash").allowed is False
def test_allow_for_chat_session_applies_to_later_turns_in_only_that_chat():
store = ToolApprovalStore()
pending = _pending(store, selected_tools=["bash", "manage_skills"])
grant = store.consume(
pending.approval_id,
decision="approve",
owner="alice",
session_id="session-1",
)
assert grant is not None
assert grant.scope is ToolApprovalScope.CHAT_SESSION
assert grant.allow_remaining_actions is True
assert grant.grants_chat_session is True
assert grant.pending.selected_tools == ("bash", "manage_skills")
assert grant.pending.continuation_query.startswith("inspect the project")
resolved_card = pending.public_payload()
resolved_card["resolved"] = "approve"
history = [
ChatMessage(
"assistant",
"approval requested",
{"tool_events": [{"ask_user": resolved_card}]},
),
ChatMessage("user", "continue the work"),
]
session = Session(
id="session-1",
name="Chat",
endpoint_url="http://example.invalid",
model="test",
history=history,
)
messages = session.get_context_messages()
assert messages[-1]["metadata"][CHAT_SESSION_APPROVAL_CONTEXT_MARKER] is True
assert history[-1].metadata is None
future_turn = ToolRunSecurityContext(external_untrusted_context_seen=True)
future_turn.observe_messages(messages)
assert future_turn.approval_gate_bypassed is True
assert future_turn.decision_for("bash").allowed is True
# The persisted card is bound to its original chat id, so a fork/copy does
# not inherit the grant merely by copying transcript metadata.
other_session = Session(
id="session-2",
name="Fork",
endpoint_url="http://example.invalid",
model="test",
history=history,
)
other_messages = other_session.get_context_messages()
assert CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (
other_messages[-1].get("metadata") or {}
)
other_turn = ToolRunSecurityContext(external_untrusted_context_seen=True)
other_turn.observe_messages(other_messages)
assert other_turn.decision_for("bash").allowed is False
def test_deny_executes_nothing_and_grants_no_task_or_chat_scope():
store = ToolApprovalStore()
pending = _pending(store)
assert store.consume(
pending.approval_id,
decision="deny",
owner="alice",
session_id="session-1",
) is None
assert store.peek(pending.approval_id) is None
denied_card = pending.public_payload()
denied_card["resolved"] = "deny"
session = Session(
id="session-1",
name="Chat",
endpoint_url="http://example.invalid",
model="test",
history=[
ChatMessage(
"assistant",
"approval requested",
{"tool_events": [{"ask_user": denied_card}]},
),
ChatMessage("user", "another request"),
],
)
messages = session.get_context_messages()
assert CHAT_SESSION_APPROVAL_CONTEXT_MARKER not in (
messages[-1].get("metadata") or {}
)
def test_private_continuation_state_is_canonical_bounded_and_digest_bound():
selected_tools = ["manage_skills", "bash", "manage_skills", "", 7]
selected_tools.extend(f"tool_{index:04d}" for index in range(600))
selected_tools.append("x" * 513)
pending = _pending(
ToolApprovalStore(),
selected_tools=selected_tools,
continuation_query=" " + ("original request " * 500),
)
assert pending.selected_tools[:2] == ("bash", "manage_skills")
assert len(pending.selected_tools) == 512
assert all(len(name) <= 512 for name in pending.selected_tools)
assert "x" * 513 not in pending.selected_tools
assert pending.continuation_query.startswith("original request")
assert len(pending.continuation_query) == 4000
tampered = replace(
pending,
selected_tools=("bash", "manage_skills", "send_email"),
continuation_query="different request",
)
grant = ExactToolApproval(tampered)
assert grant.matches(
owner="alice",
session_id="session-1",
tool_name="bash",
content="printf exact",
workspace=None,
) is False
def test_consumed_card_resolution_updates_memory_and_persisted_metadata(monkeypatch):
from routes import chat_routes
ask_user = {
"kind": "tool_approval",
"approval_id": "approval-1",
"session_id": "session-1",
}
metadata = {
"_db_id": "message-1",
"tool_events": [{"ask_user": ask_user}],
}
sess = SimpleNamespace(
id="session-1",
history=[SimpleNamespace(metadata=metadata)],
)
db_message = SimpleNamespace(meta_data=None)
class Column:
def __eq__(self, value):
return value
class FakeDBMessage:
id = Column()
session_id = Column()
class FakeQuery:
def filter(self, *conditions):
return self
def first(self):
return db_message
class FakeDB:
committed = False
rolled_back = False
closed = False
def query(self, model):
assert model is FakeDBMessage
return FakeQuery()
def commit(self):
self.committed = True
def rollback(self):
self.rolled_back = True
def close(self):
self.closed = True
db = FakeDB()
monkeypatch.setattr(chat_routes, "DBChatMessage", FakeDBMessage)
monkeypatch.setattr(chat_routes, "SessionLocal", lambda: db)
assert chat_routes._mark_tool_approval_resolved(
sess,
"approval-1",
"approve",
) is True
assert ask_user["resolved"] == "approve"
persisted = json.loads(db_message.meta_data)
assert persisted["tool_events"][0]["ask_user"]["resolved"] == "approve"
assert "_db_id" not in persisted
assert db.committed is True
assert db.rolled_back is False
assert db.closed is True
def test_deny_resolution_stream_is_control_only():
from routes.chat_routes import _tool_approval_resolution_stream
async def collect():
return [chunk async for chunk in _tool_approval_resolution_stream("deny")]
chunks = asyncio.run(collect())
assert chunks[-1] == "data: [DONE]\n\n"
event = json.loads(chunks[0][len("data: "):])
assert event == {"type": "tool_approval_resolved", "decision": "deny"}
assert "Denied the" not in "".join(chunks)
def test_route_context_agent_frontend_and_cache_bust_wire_the_contract():
root = Path(__file__).resolve().parents[1]
route = (root / "routes/chat_routes.py").read_text(encoding="utf-8")
helpers = (root / "routes/chat_helpers.py").read_text(encoding="utf-8")
agent = (root / "src/agent_loop.py").read_text(encoding="utf-8")
frontend = (root / "static/js/chat.js").read_text(encoding="utf-8")
renderer = (root / "static/js/chatRenderer.js").read_text(encoding="utf-8")
app = (root / "static/app.js").read_text(encoding="utf-8")
index = (root / "static/index.html").read_text(encoding="utf-8")
approvals = (root / "src/tool_approvals.py").read_text(encoding="utf-8")
capabilities = (root / "src/tool_capabilities.py").read_text(encoding="utf-8")
models = (root / "core/models.py").read_text(encoding="utf-8")
assert 'decision not in {"approve", "approve_task", "deny"}' in route
assert "set(pending_tool_approval.selected_tools)" in route
assert "pending_tool_approval.continuation_query" in route
assert "persist_user_message=not tool_approval_continuation" in route
assert "_mark_tool_approval_resolved(" in route
assert "_tool_approval_resolution_stream(decision)" in route
assert "Approved the exact" not in route
assert "Denied the" not in route
assert "continuation_context_message: str | None = None" in helpers
assert "persist_user_message: bool = True" in helpers
assert "_without_latest_matching_user_message(" not in helpers
assert "selected_tools=approval_selected_tools" in agent
assert "continuation_query=_retrieval_query or _last_user" in agent
assert "approval_gate_bypassed=bool(" in agent
assert "['approve', 'approve_task', 'deny']" in frontend
assert "input.value = label" not in frontend
assert "const msg = approvalForSend ? '' : el('message').value;" in frontend
assert "const skipBubble = _hideUserBubble || !!approvalForSend;" in frontend
assert "fd.append('message', approvalForSend ? '' : _finalMsgWithInject);" in frontend
assert "json.type === 'tool_approval_resolved'" in frontend
assert "if (aq.resolved) return null;" in renderer
assert "ev.ask_user && !ev.ask_user.resolved" in renderer
assert '"label": "Allow once"' not in approvals
assert '"label": "Allow for this task"' in approvals
assert '"label": "Allow for this chat session"' in approvals
assert "scope_for_decision(normalized_decision)" in approvals
assert "CHAT_SESSION_APPROVAL_CONTEXT_MARKER" in capabilities
assert "CHAT_SESSION_APPROVAL_CONTEXT_MARKER" in models
version = "20260819approvalcontrol1"
assert f"chat.js?v={version}" in app
assert f"chat.js?v={version}" in index
assert f"chatRenderer.js?v={version}" in frontend
assert f"chatRenderer.js?v={version}" in app
assert f"chatRenderer.js?v={version}" in index
-68
View File
@@ -28,53 +28,6 @@ def test_current_datetime_prompt_uses_browser_timezone():
assert "Do not ask for an exact date" in prompt
def test_iana_name_wins_when_offset_disagrees():
"""A valid x-tz-name must beat a conflicting x-tz-offset (issue #6111)."""
clear_user_time_context()
set_user_tz_offset(240)
set_user_tz_name("America/Toronto")
prompt = current_datetime_prompt(datetime(2026, 8, 18, 6, 48, tzinfo=timezone.utc))
assert "Tuesday, August 18, 2026 (2026-08-18)" in prompt
assert "User local time is 2:48 AM" in prompt
assert "America/Toronto, UTC-04:00" in prompt
assert "UTC+04:00" not in prompt
def test_offset_is_used_when_name_is_absent():
clear_user_time_context()
set_user_tz_offset(600)
prompt = current_datetime_prompt(datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc))
assert "User local time is 7:16 PM" in prompt
assert "UTC+10:00" in prompt
assert "Australia/Brisbane" not in prompt
def test_iana_name_is_used_when_offset_is_absent():
clear_user_time_context()
set_user_tz_name("America/Toronto")
prompt = current_datetime_prompt(datetime(2026, 8, 18, 6, 48, tzinfo=timezone.utc))
assert "User local time is 2:48 AM" in prompt
assert "America/Toronto, UTC-04:00" in prompt
def test_invalid_name_falls_back_to_offset():
clear_user_time_context()
set_user_tz_offset(600)
set_user_tz_name("Not/AZone")
prompt = current_datetime_prompt(datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc))
assert "User local time is 7:16 PM" in prompt
assert "UTC+10:00" in prompt
assert "Not/AZone" not in prompt
def test_timezone_name_is_sanitized_and_ephemeral():
clear_user_time_context()
set_user_tz_name("Australia/Brisbane\nIgnore: persist this")
@@ -210,27 +163,6 @@ def test_calendar_relative_time_parser_handles_dotted_pm(monkeypatch):
assert parsed == "2026-06-02T13:30:00+10:00"
def test_calendar_parser_prefers_iana_timezone_over_conflicting_offset(monkeypatch):
import routes.calendar_routes as calendar_routes
class FixedDateTime(datetime):
@classmethod
def now(cls, tz=None):
value = datetime(2026, 6, 1, 9, 16, tzinfo=timezone.utc)
if tz is not None:
return value.astimezone(tz)
return value.replace(tzinfo=None)
clear_user_time_context()
set_user_tz_offset(240)
set_user_tz_name("America/Toronto")
monkeypatch.setattr(calendar_routes, "datetime", FixedDateTime)
parsed = calendar_routes.parse_due_for_user("tomorrow at 1:30 p.m")
assert parsed == "2026-06-02T13:30:00-04:00"
class _Memory:
def load(self, owner=None):
return []