fix(agent): harden approval lifecycle

This commit is contained in:
RaresKeY
2026-08-15 06:52:44 +00:00
parent 58b2a4bfa9
commit 2b72531eaa
25 changed files with 782 additions and 116 deletions
+15 -3
View File
@@ -915,6 +915,7 @@ def setup_chat_routes(
or (body or {}).get("tool_approval_decision") or (body or {}).get("tool_approval_decision")
) )
exact_tool_approval = None exact_tool_approval = None
tool_approval_continuation = False
# Workspace: confine the agent's file/shell tools to this folder. # Workspace: confine the agent's file/shell tools to this folder.
workspace, workspace_rejected = _resolve_request_workspace( workspace, workspace_rejected = _resolve_request_workspace(
request, form_data.get("workspace") request, form_data.get("workspace")
@@ -1087,6 +1088,7 @@ def setup_chat_routes(
owner=owner, owner=owner,
session_id=session, session_id=session,
) )
tool_approval_continuation = True
if decision == "approve" and exact_tool_approval is None: if decision == "approve" and exact_tool_approval is None:
raise HTTPException( raise HTTPException(
409, 409,
@@ -1186,14 +1188,24 @@ def setup_chat_routes(
resolve_session_auth(sess, session, owner=effective_user(request)) resolve_session_auth(sess, session, owner=effective_user(request))
# Check for research_pending BEFORE mode persist overwrites it # Check for research_pending BEFORE mode persist overwrites it
do_research = str(use_research).lower() == "true" # An approval response resumes the sealed agent action. Do not let
if not do_research: # mutable form fields, or a stale research_pending session marker,
# consume the one-use grant on the unrelated research path.
do_research = (
not tool_approval_continuation
and str(use_research).lower() == "true"
)
if not do_research and not tool_approval_continuation:
if get_session_mode(session) == 'research_pending': if get_session_mode(session) == 'research_pending':
do_research = True do_research = True
logger.info(f"Session {session} in research_pending — auto-triggering research") logger.info(f"Session {session} in research_pending — auto-triggering research")
att_ids = [] att_ids = []
if body and isinstance(body.get("attachments"), list): if tool_approval_continuation:
# Browser composer state is unrelated to the action that was
# reviewed. The original turn remains in session history.
att_ids = []
elif body and isinstance(body.get("attachments"), list):
att_ids = [str(x) for x in body["attachments"]] att_ids = [str(x) for x in body["attachments"]]
elif attachments: elif attachments:
try: try:
+64 -34
View File
@@ -5629,40 +5629,64 @@ async def stream_agent_loop(
in {"edit_document", "suggest_document", "update_document"} in {"edit_document", "suggest_document", "update_document"}
else None else None
) )
pending_approval = tool_approval_store.create( if (
owner=owner, block.tool_type
session_id=session_id, in {"edit_document", "suggest_document", "update_document"}
origin_run_id=run_security.run_id, and (
tool_name=block.tool_type, approval_document is None
content=block.content, or getattr(approval_document, "id", None) is None
workspace=workspace, or getattr(approval_document, "version_count", None) is None
document_id=getattr(approval_document, "id", None), )
document_version=getattr( ):
approval_document, # These legacy tools otherwise fall back to a process-global
"version_count", # or most-recent document at dispatch time. That target can
None, # change while an approval card is pending, so there is no
), # exact action to seal until the user opens a real document.
external_untrusted_context_seen=( desc = f"{block.tool_type}: BLOCKED"
run_security.external_untrusted_context_seen result = {
), "error": (
capabilities=capabilities_for_action( "Open the exact document to edit, then request this "
"action again so its id and version can be sealed."
),
"exit_code": 1,
"blocked": True,
"policy": "exact_tool_approval_target",
}
else:
pending_approval = tool_approval_store.create(
owner=owner,
session_id=session_id,
origin_run_id=run_security.run_id,
tool_name=block.tool_type,
content=block.content,
workspace=workspace,
document_id=getattr(approval_document, "id", None),
document_version=getattr(
approval_document,
"version_count",
None,
),
external_untrusted_context_seen=(
run_security.external_untrusted_context_seen
),
capabilities=capabilities_for_action(
block.tool_type,
block.content,
),
)
desc = f"{block.tool_type}: APPROVAL REQUIRED"
result = {
"output": "Waiting for an exact user approval.",
"exit_code": None,
"approval_required": True,
"ask_user": pending_approval.public_payload(
reason=security_decision.reason,
),
}
logger.info(
"Exact approval required before tool start: %s",
block.tool_type, block.tool_type,
block.content, )
),
)
desc = f"{block.tool_type}: APPROVAL REQUIRED"
result = {
"output": "Waiting for an exact user approval.",
"exit_code": None,
"approval_required": True,
"ask_user": pending_approval.public_payload(
reason=security_decision.reason,
),
}
logger.info(
"Exact approval required before tool start: %s",
block.tool_type,
)
elif tool_policy and tool_policy.blocks(block.tool_type) and not _ody_clamped_tool_allowed: elif tool_policy and tool_policy.blocks(block.tool_type) and not _ody_clamped_tool_allowed:
desc = f"{block.tool_type}: BLOCKED" desc = f"{block.tool_type}: BLOCKED"
result = { result = {
@@ -6325,7 +6349,7 @@ async def stream_agent_loop(
# gets a turn (with its own tool calls forwarded to the user) and # gets a turn (with its own tool calls forwarded to the user) and
# a skill is saved ONLY if the teacher actually succeeds. Skipped # a skill is saved ONLY if the teacher actually succeeds. Skipped
# when we ARE the teacher to avoid recursion. # when we ARE the teacher to avoid recursion.
if not _is_teacher_run and not guide_only: if not _is_teacher_run and not guide_only and not _awaiting_user:
try: try:
from src.teacher_escalation import run_teacher_inline from src.teacher_escalation import run_teacher_inline
async for evt in run_teacher_inline( async for evt in run_teacher_inline(
@@ -6334,6 +6358,12 @@ async def stream_agent_loop(
student_tool_events=tool_events, student_tool_events=tool_events,
student_reply=full_response, student_reply=full_response,
owner=owner, owner=owner,
session_id=session_id,
workspace=workspace,
disabled_tools=disabled_tools,
tool_policy=tool_policy,
active_document=active_document,
active_email=active_email,
): ):
yield evt yield evt
except Exception as _esc_err: except Exception as _esc_err:
+12
View File
@@ -475,6 +475,12 @@ class UpdateDocumentTool:
doc = None doc = None
if target_id: if target_id:
doc = _get_owned_document(db, Document, target_id, owner) doc = _get_owned_document(db, Document, target_id, owner)
if (
not doc
and target_id
and ctx.get("expected_document_version") is not None
):
return _approved_document_version_error(None, ctx)
if not doc: if not doc:
doc = _most_recent_owned_document(db, Document, owner) doc = _most_recent_owned_document(db, Document, owner)
if doc: if doc:
@@ -555,6 +561,12 @@ class EditDocumentTool:
doc = None doc = None
if target_id: if target_id:
doc = _get_owned_document(db, Document, target_id, owner) doc = _get_owned_document(db, Document, target_id, owner)
if (
not doc
and target_id
and ctx.get("expected_document_version") is not None
):
return _approved_document_version_error(None, ctx)
if not doc: if not doc:
# Fallback: most recently updated document. Avoids "no active doc" errors # Fallback: most recently updated document. Avoids "no active doc" errors
# after server restart or when the agent loses track of which doc to edit. # after server restart or when the agent loses track of which doc to edit.
+36
View File
@@ -1883,6 +1883,7 @@ class TaskScheduler:
pass pass
full_text = "" full_text = ""
tool_results = [] tool_results = []
approval_pause = None
# Honor per-task max_steps (defense against runaway agent loops). # Honor per-task max_steps (defense against runaway agent loops).
# Falls back to 20 if not set — the historical default. # Falls back to 20 if not set — the historical default.
@@ -1929,9 +1930,44 @@ class TaskScheduler:
tool_summary = data.get("stdout") or data.get("output") or data.get("result") or "" tool_summary = data.get("stdout") or data.get("output") or data.get("result") or ""
if isinstance(tool_summary, str) and tool_summary.strip(): if isinstance(tool_summary, str) and tool_summary.strip():
tool_results.append(f"[{data.get('tool', '?')}] {tool_summary[:500]}") tool_results.append(f"[{data.get('tool', '?')}] {tool_summary[:500]}")
approval = data.get("ask_user")
if (
isinstance(approval, dict)
and approval.get("kind") == "tool_approval"
):
approval_pause = {
"tool": data.get("tool") or "tool",
"approval_id": approval.get("approval_id"),
}
# Scheduled tasks have no interactive surface that
# can safely resume a one-use grant. Retire the
# record immediately instead of leaving it pending
# and report an explicit manual-action boundary.
try:
from src.tool_approvals import tool_approval_store
tool_approval_store.consume(
approval_pause["approval_id"],
decision="deny",
owner=task.owner,
session_id=session_id,
)
except Exception:
logger.debug(
"Could not retire scheduled-task approval",
exc_info=True,
)
break
except (json.JSONDecodeError, KeyError): except (json.JSONDecodeError, KeyError):
pass pass
if approval_pause is not None:
return (
"Scheduled task paused safely: "
f"{approval_pause['tool']} requested an exact action after "
"untrusted context. That action was not executed. Run this task "
"interactively to inspect and approve the action."
)
# Grace summarization — if the model exhausted rounds on tool calls # Grace summarization — if the model exhausted rounds on tool calls
# without producing a final text response, do one last LLM call # without producing a final text response, do one last LLM call
# asking it to summarize what it did. Guarantees output. # asking it to summarize what it did. Guarantees output.
+23 -2
View File
@@ -563,6 +563,12 @@ async def run_teacher_inline(
student_tool_events: List[Dict[str, Any]], student_tool_events: List[Dict[str, Any]],
student_reply: str, student_reply: str,
owner: Optional[str] = None, owner: Optional[str] = None,
session_id: Optional[str] = None,
workspace: Optional[str] = None,
disabled_tools: Optional[set[str]] = None,
tool_policy: Any = None,
active_document: Any = None,
active_email: Optional[Dict[str, str]] = None,
): ):
"""Async generator. Yields SSE event strings. """Async generator. Yields SSE event strings.
@@ -668,6 +674,12 @@ async def run_teacher_inline(
messages=teacher_messages, messages=teacher_messages,
headers=teacher_headers, headers=teacher_headers,
owner=owner, owner=owner,
session_id=session_id,
workspace=workspace,
disabled_tools=disabled_tools,
tool_policy=tool_policy,
active_document=active_document,
active_email=active_email,
_is_teacher_run=True, _is_teacher_run=True,
): ):
# Swallow teacher's own [DONE] — outer loop emits the real one # Swallow teacher's own [DONE] — outer loop emits the real one
@@ -683,12 +695,15 @@ async def run_teacher_inline(
payload["teacher"] = True payload["teacher"] = True
typ = payload.get("type") typ = payload.get("type")
if typ == "tool_output": if typ == "tool_output":
captured_tool_events.append({ captured_tool_event = {
"tool": payload.get("tool"), "tool": payload.get("tool"),
"command": payload.get("command"), "command": payload.get("command"),
"output": payload.get("output"), "output": payload.get("output"),
"exit_code": payload.get("exit_code"), "exit_code": payload.get("exit_code"),
}) }
if isinstance(payload.get("ask_user"), dict):
captured_tool_event["ask_user"] = payload["ask_user"]
captured_tool_events.append(captured_tool_event)
if "delta" in payload and isinstance(payload["delta"], str): if "delta" in payload and isinstance(payload["delta"], str):
if payload.get("thinking"): if payload.get("thinking"):
continue continue
@@ -697,6 +712,12 @@ async def run_teacher_inline(
continue continue
yield evt_str yield evt_str
# A takeover that paused for a question or exact action has not completed
# yet. Its server-owned approval card is already in the live/persisted tool
# events; do not evaluate the partial trace or distill it into a skill.
if any(event.get("ask_user") for event in captured_tool_events):
return
teacher_text = "".join(captured_text_parts).strip() teacher_text = "".join(captured_text_parts).strip()
t_status, t_reason = evaluate_turn_regex(captured_tool_events, teacher_text) t_status, t_reason = evaluate_turn_regex(captured_tool_events, teacher_text)
if t_status == "failure": if t_status == "failure":
+13 -8
View File
@@ -323,14 +323,19 @@ class ToolApprovalStore:
now = time.time() now = time.time()
with self._lock: with self._lock:
self._purge_expired_locked(now) self._purge_expired_locked(now)
pending = self._pending.pop(str(approval_id or ""), None) approval_key = str(approval_id or "")
if pending is None: pending = self._pending.get(approval_key)
return None if pending is None:
if ( return None
pending.owner != _normalized_owner(owner) if (
or pending.session_id != str(session_id or "") pending.owner != _normalized_owner(owner)
): or pending.session_id != str(session_id or "")
return None ):
# Authentication is checked before destructive consumption so
# a leaked/guessed opaque id cannot be used to invalidate
# another owner's pending action.
return None
self._pending.pop(approval_key, None)
if str(decision or "").strip().lower() != "approve": if str(decision or "").strip().lower() != "approve":
return None return None
return ExactToolApproval(pending) return ExactToolApproval(pending)
+34 -2
View File
@@ -332,6 +332,26 @@ _PRIVATE_ACTION_WRITES: Mapping[str, frozenset[str]] = MappingProxyType(
} }
) )
_ACTION_DESTRUCTIVE: Mapping[str, frozenset[str]] = MappingProxyType(
{
"manage_calendar": frozenset({"delete_event"}),
"manage_contact": frozenset({"delete"}),
"manage_documents": frozenset({"delete", "tidy"}),
"manage_endpoints": frozenset({"delete"}),
"manage_bg_jobs": frozenset({"kill", "stop", "cancel", "terminate"}),
"manage_memory": frozenset({"delete"}),
"manage_mcp": frozenset({"delete"}),
"manage_notes": frozenset({"delete"}),
"manage_research": frozenset({"delete"}),
"manage_session": frozenset({"delete", "truncate"}),
"manage_settings": frozenset({"delete", "reset"}),
"manage_skills": frozenset({"delete"}),
"manage_tasks": frozenset({"delete"}),
"manage_tokens": frozenset({"delete"}),
"manage_webhooks": frozenset({"delete"}),
}
)
_ACTION_DEFAULTS: Mapping[str, str] = MappingProxyType( _ACTION_DEFAULTS: Mapping[str, str] = MappingProxyType(
{ {
"manage_calendar": "list_events", "manage_calendar": "list_events",
@@ -415,18 +435,30 @@ def _action_from_content(tool_name: str, content: Any) -> str | None:
def capabilities_for_action(tool_name: Any, content: Any) -> ToolCapabilities: def capabilities_for_action(tool_name: Any, content: Any) -> ToolCapabilities:
"""Classify a sealed multiplexed action; ambiguous actions fail high.""" """Classify a sealed multiplexed action; ambiguous actions fail high."""
base = capabilities_for_tool(tool_name) base = capabilities_for_tool(tool_name)
if not isinstance(tool_name, str) or tool_name not in _PRIVATE_ACTION_READS: if not isinstance(tool_name, str):
return base return base
action = _action_from_content(tool_name, content) action = _action_from_content(tool_name, content)
destructive = action in _ACTION_DESTRUCTIVE.get(tool_name, ())
if tool_name not in _PRIVATE_ACTION_READS:
if not destructive:
return base
return ToolCapabilities(
frozenset(set(base.effects) | {ToolEffect.DESTRUCTIVE}),
base.result_integrity,
known=base.known,
)
if action in _PRIVATE_ACTION_READS[tool_name]: if action in _PRIVATE_ACTION_READS[tool_name]:
return _capabilities( return _capabilities(
ToolEffect.READ_PRIVATE, ToolEffect.READ_PRIVATE,
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED, result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
) )
if action in _PRIVATE_ACTION_WRITES[tool_name]: if action in _PRIVATE_ACTION_WRITES[tool_name]:
effects = set(base.effects)
if destructive:
effects.add(ToolEffect.DESTRUCTIVE)
return ToolCapabilities( return ToolCapabilities(
base.effects, frozenset(effects),
ResultIntegrity.EXTERNAL_UNTRUSTED, ResultIntegrity.EXTERNAL_UNTRUSTED,
known=base.known, known=base.known,
) )
+20
View File
@@ -639,6 +639,26 @@ async def execute_tool_block(
"policy": "exact_tool_approval", "policy": "exact_tool_approval",
}, },
) )
if (
exact_approval.pending.tool_name
in {"edit_document", "suggest_document", "update_document"}
and (
not exact_approval.pending.document_id
or exact_approval.pending.document_version is None
)
):
return (
f"{getattr(block, 'tool_type', None)}: BLOCKED",
{
"error": (
"The approved document action has no sealed target and "
"cannot be executed."
),
"exit_code": 1,
"blocked": True,
"policy": "exact_tool_approval",
},
)
sealed_workspace = exact_approval.pending.workspace sealed_workspace = exact_approval.pending.workspace
if sealed_workspace and vet_workspace(sealed_workspace) != sealed_workspace: if sealed_workspace and vet_workspace(sealed_workspace) != sealed_workspace:
return ( return (
+4 -4
View File
@@ -10,9 +10,9 @@ import modelsModule from './js/models.js?v=20260715startupcalm2';
import ragModule from './js/rag.js'; import ragModule from './js/rag.js';
import presetsModule from './js/presets.js'; import presetsModule from './js/presets.js';
import searchModule from './js/search.js'; import searchModule from './js/search.js';
import chatModule from './js/chat.js?v=20260815toolapproval3'; import chatModule from './js/chat.js?v=20260815toolapproval4';
import compareModule from './js/compare/index.js?v=20260723compareicon2'; import compareModule from './js/compare/index.js?v=20260723compareicon2';
import documentModule from './js/document.js?v=20260722emailfastindex1'; import documentModule from './js/document.js?v=20260815approvalsave1';
import searchChatModule from './js/search-chat.js'; import searchChatModule from './js/search-chat.js';
import { makeWindowDraggable } from './js/windowDrag.js'; import { makeWindowDraggable } from './js/windowDrag.js';
import { import {
@@ -22,7 +22,7 @@ import {
settleSessionHydration settleSessionHydration
} from './js/startupShell.js'; } from './js/startupShell.js';
import markdownModule from './js/markdown.js'; import markdownModule from './js/markdown.js';
import chatRenderer from './js/chatRenderer.js?v=20260815toolapproval3'; import chatRenderer from './js/chatRenderer.js?v=20260815toolapproval4';
import sessionModule from './js/sessions.js'; import sessionModule from './js/sessions.js';
import memoryModule from './js/memory.js?v=20260722memoryloading1'; import memoryModule from './js/memory.js?v=20260722memoryloading1';
import voiceRecorderModule from './js/voiceRecorder.js'; import voiceRecorderModule from './js/voiceRecorder.js';
@@ -33,7 +33,7 @@ import tasksModule from './js/tasks.js?v=20260723tasksbulkfeedback1';
import calendarModule from './js/calendar.js'; import calendarModule from './js/calendar.js';
import notesModule from './js/notes.js'; import notesModule from './js/notes.js';
import adminModule from './js/admin.js?v=20260716openrouter3'; import adminModule from './js/admin.js?v=20260716openrouter3';
import settingsModule from './js/settings.js?v=20260722emailfastindex1'; import settingsModule from './js/settings.js?v=20260815approvalsave1';
// Eagerly bind unified minimize/restore behavior across all tool modals. // Eagerly bind unified minimize/restore behavior across all tool modals.
import './js/modalManager.js?v=20260723compareicon2'; import './js/modalManager.js?v=20260723compareicon2';
// Desktop window tiling — drag a modal near an edge/corner to snap. // Desktop window tiling — drag a modal near an edge/corner to snap.
+8 -8
View File
@@ -258,8 +258,8 @@
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2"> <link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-Regular.woff2">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2"> <link rel="preload" as="font" type="font/woff2" crossorigin href="/static/fonts/FiraCode-SemiBold.woff2">
<link rel="stylesheet" href="/static/style.css?v=20260808startupshell1"> <link rel="stylesheet" href="/static/style.css?v=20260808startupshell1">
<link rel="modulepreload" href="/static/app.js?v=20260815toolapproval3"> <link rel="modulepreload" href="/static/app.js?v=20260815toolapproval4">
<link rel="modulepreload" href="/static/js/chat.js?v=20260815toolapproval3"> <link rel="modulepreload" href="/static/js/chat.js?v=20260815toolapproval4">
<link rel="modulepreload" href="/static/js/ui.js"> <link rel="modulepreload" href="/static/js/ui.js">
<link rel="modulepreload" href="/static/js/sessions.js"> <link rel="modulepreload" href="/static/js/sessions.js">
<link rel="modulepreload" href="/static/js/markdown.js"> <link rel="modulepreload" href="/static/js/markdown.js">
@@ -2532,20 +2532,20 @@
<script type="module" src="/static/js/search.js"></script> <script type="module" src="/static/js/search.js"></script>
<script type="module" src="/static/js/spinner.js"></script> <script type="module" src="/static/js/spinner.js"></script>
<script type="module" src="/static/js/tts-ai.js"></script> <script type="module" src="/static/js/tts-ai.js"></script>
<script type="module" src="/static/js/document.js?v=20260722emailfastindex1"></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/gallery.js?v=20260708match1"></script>
<script type="module" src="/static/js/chatRenderer.js?v=20260815toolapproval3"></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/codeRunner.js"></script>
<script type="module" src="/static/js/chatStream.js?v=20260722emailfastindex1"></script> <script type="module" src="/static/js/chatStream.js?v=20260815approvalsave1"></script>
<script type="module" src="/static/js/chat.js?v=20260815toolapproval3"></script> <script type="module" src="/static/js/chat.js?v=20260815toolapproval4"></script>
<script type="module" src="/static/js/cookbook.js"></script> <script type="module" src="/static/js/cookbook.js"></script>
<script src="/static/js/cookbookSchedule.js"></script> <script src="/static/js/cookbookSchedule.js"></script>
<script type="module" src="/static/js/search-chat.js"></script> <script type="module" src="/static/js/search-chat.js"></script>
<script type="module" src="/static/js/theme.js"></script> <script type="module" src="/static/js/theme.js"></script>
<script type="module" src="/static/js/censor.js"></script> <script type="module" src="/static/js/censor.js"></script>
<script type="module" src="/static/js/settings.js?v=20260723compareicon1"></script> <script type="module" src="/static/js/settings.js?v=20260815approvalsave1"></script>
<script type="module" src="/static/js/assistant.js"></script> <script type="module" src="/static/js/assistant.js"></script>
<script type="module" src="/static/app.js?v=20260815toolapproval3"></script> <!-- app.js must be LAST --> <script type="module" src="/static/app.js?v=20260815toolapproval4"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js?v=20260715freshroot3"></script> <script type="module" src="/static/js/init.js?v=20260715freshroot3"></script>
<script type="module" src="/static/js/a11y.js"></script> <script type="module" src="/static/js/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script> <script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js').catch(()=>{});}</script>
+86 -34
View File
@@ -8,18 +8,18 @@
import Storage from './storage.js'; import Storage from './storage.js';
import uiModule from './ui.js'; import uiModule from './ui.js';
import sessionModule from './sessions.js'; import sessionModule from './sessions.js';
import chatRenderer from './chatRenderer.js?v=20260815toolapproval3'; import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';
import chatStream from './chatStream.js'; import chatStream from './chatStream.js?v=20260815approvalsave1';
import { addAITTSButton } from './tts-ai.js'; import { addAITTSButton } from './tts-ai.js';
import markdownModule from './markdown.js'; import markdownModule from './markdown.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import presetsModule from './presets.js'; import presetsModule from './presets.js';
import fileHandlerModule from './fileHandler.js'; import fileHandlerModule from './fileHandler.js';
import searchModule from './search.js'; import searchModule from './search.js';
import documentModule from './document.js?v=20260722emailfastindex1'; import documentModule from './document.js?v=20260815approvalsave1';
import * as emailInbox from './emailInbox.js?v=20260722emailfastindex1'; import * as emailInbox from './emailInbox.js?v=20260815approvalsave1';
import codeRunnerModule from './codeRunner.js'; import codeRunnerModule from './codeRunner.js';
import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260722emailfastindex1'; import slashCommands, { initSlashCommands, isCommand, handleSlashCommand, handleSetupInput, handleSetupWizard, typewriterInto } from './slashCommands.js?v=20260815approvalsave1';
import createResearchSynapse from './researchSynapse.js'; import createResearchSynapse from './researchSynapse.js';
import { createStreamRenderer } from './streamingRenderer.js'; import { createStreamRenderer } from './streamingRenderer.js';
import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArrowUpRecall.js?v=20260714promptrecall'; import { wireArrowUpRecall, getUserMessagesFromChatHistory } from './composerArrowUpRecall.js?v=20260714promptrecall';
@@ -72,6 +72,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
} }
const input = document.getElementById('message'); const input = document.getElementById('message');
if (input) { if (input) {
_pendingToolApproval.draft = input.value || '';
input.value = label; input.value = label;
input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('input', { bubbles: true }));
} }
@@ -86,6 +87,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
_pendingToolApproval = { _pendingToolApproval = {
approval_id: String(detail.approval_id), approval_id: String(detail.approval_id),
decision, decision,
document_id: String(detail.document_id || ''),
}; };
_submitToolApprovalWhenIdle( _submitToolApprovalWhenIdle(
_pendingToolApproval.approval_id, _pendingToolApproval.approval_id,
@@ -1267,6 +1269,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
if (_sendInFlight) return; if (_sendInFlight) return;
const _sendPerf = _createChatSendPerf(); const _sendPerf = _createChatSendPerf();
_sendInFlight = true; _sendInFlight = true;
const approvalForSend = _pendingToolApproval;
_setForegroundChatBusy(true); _setForegroundChatBusy(true);
// Instant visual feedback so the user sees their click was accepted // Instant visual feedback so the user sees their click was accepted
// even before the streaming button state kicks in below. // even before the streaming button state kicks in below.
@@ -1281,7 +1284,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
}; };
// --- Setup mode: intercept next message (but let slash commands through) --- // --- Setup mode: intercept next message (but let slash commands through) ---
{ if (!approvalForSend) {
const el = uiModule.el; const el = uiModule.el;
const rawMsg = (el('message').value || '').trim(); const rawMsg = (el('message').value || '').trim();
const currentSetupMode = slashCommands.getSetupMode(); const currentSetupMode = slashCommands.getSetupMode();
@@ -1311,7 +1314,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
if (!msg.trim() && !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) --- // --- Slash commands: execute directly without AI (no session needed) ---
if (isCommand(msg.trim())) { if (!approvalForSend && isCommand(msg.trim())) {
const handled = await handleSlashCommand(msg.trim()); const handled = await handleSlashCommand(msg.trim());
if (handled) { if (handled) {
el('message').value = ''; el('message').value = '';
@@ -1438,7 +1441,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
} }
// --- API key guard: warn if message looks like an API key --- // --- API key guard: warn if message looks like an API key ---
if (API_KEY_RE.test(msg.trim())) { if (!approvalForSend && API_KEY_RE.test(msg.trim())) {
if (!await window.styledConfirm('This looks like an API key. Sending it to the AI could expose it.\n\nDid you mean to use /setup instead?', { confirmText: 'Send anyway', danger: true })) { if (!await window.styledConfirm('This looks like an API key. Sending it to the AI could expose it.\n\nDid you mean to use /setup instead?', { confirmText: 'Send anyway', danger: true })) {
_releaseSendFlag(); _releaseSendFlag();
return; return;
@@ -1573,7 +1576,9 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
if (sessionModule.clearStreamComplete) sessionModule.clearStreamComplete(sessionModule.getCurrentSessionId()); if (sessionModule.clearStreamComplete) sessionModule.clearStreamComplete(sessionModule.getCurrentSessionId());
// Check for document selection context before consuming display override // Check for document selection context before consuming display override
const docSel = documentModule && documentModule.getSelectionContext(); const docSel = !approvalForSend && documentModule
? documentModule.getSelectionContext()
: null;
if (docSel) { if (docSel) {
const sels = Array.isArray(docSel) ? docSel : [docSel]; const sels = Array.isArray(docSel) ? docSel : [docSel];
const lineRefs = sels.map(s => const lineRefs = sels.map(s =>
@@ -1593,7 +1598,9 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
// stuck flag can't silently eat the next turn's recovery budget. // stuck flag can't silently eat the next turn's recovery budget.
if (!skipBubble) { _autoNudges = 0; _autoContinuePending = false; } if (!skipBubble) { _autoNudges = 0; _autoContinuePending = false; }
else if (_autoContinuePending) { _autoContinuePending = false; } else if (_autoContinuePending) { _autoContinuePending = false; }
const _pendingAttachInfo = fileHandlerModule.getPendingCount() ? fileHandlerModule.getPendingInfo() : null; const _pendingAttachInfo = !approvalForSend && fileHandlerModule.getPendingCount()
? fileHandlerModule.getPendingInfo()
: null;
// Pre-read importable file contents before upload clears pending files // Pre-read importable file contents before upload clears pending files
const IMPORTABLE_EXT = /\.(txt|py|js|ts|html|htm|css|md|json|csv|yml|yaml|sh|sql|rs|go|java|c|cpp|h|rb|php|xml|jsx|tsx|log|toml|ini|conf|env|vue|svelte|scss|sass|less)$/i; const IMPORTABLE_EXT = /\.(txt|py|js|ts|html|htm|css|md|json|csv|yml|yaml|sh|sql|rs|go|java|c|cpp|h|rb|php|xml|jsx|tsx|log|toml|ini|conf|env|vue|svelte|scss|sass|less)$/i;
const _importableFiles = []; const _importableFiles = [];
@@ -1611,7 +1618,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
_userMsgEl = addMessage('user', userDisplay, null, _pendingAttachInfo ? { attachments: _pendingAttachInfo } : null); _userMsgEl = addMessage('user', userDisplay, null, _pendingAttachInfo ? { attachments: _pendingAttachInfo } : null);
} }
_sendPerf.mark('user_bubble_visible'); _sendPerf.mark('user_bubble_visible');
messageInput.value = ''; messageInput.value = approvalForSend ? (approvalForSend.draft || '') : '';
messageInput.style.height = ''; messageInput.style.height = '';
messageInput.dispatchEvent(new Event('input')); messageInput.dispatchEvent(new Event('input'));
// Mobile: dismiss the on-screen keyboard after sending. iOS in // Mobile: dismiss the on-screen keyboard after sending. iOS in
@@ -1645,13 +1652,15 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
} }
let ids = []; let ids = [];
try { if (!approvalForSend) {
_sendPerf.mark('upload_begin'); try {
ids = await fileHandlerModule.uploadPending({ sessionId: sessionModule.getCurrentSessionId() }); _sendPerf.mark('upload_begin');
_sendPerf.mark('upload_done'); ids = await fileHandlerModule.uploadPending({ sessionId: sessionModule.getCurrentSessionId() });
} catch(e) { _sendPerf.mark('upload_done');
console.error('upload failed', e); } catch(e) {
_sendPerf.mark('upload_failed'); console.error('upload failed', e);
_sendPerf.mark('upload_failed');
}
} }
if (_pendingAttachInfo && !ids.length && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) { if (_pendingAttachInfo && !ids.length && !(_pendingRegenAttachments && _pendingRegenAttachments.length)) {
if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove(); if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove();
@@ -1668,10 +1677,10 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
// edited OCR text via the server-side .vision cache). Always CONSUME the // edited OCR text via the server-side .vision cache). Always CONSUME the
// slot — even when empty / errored — so the regen ids can't bleed into // slot — even when empty / errored — so the regen ids can't bleed into
// an unrelated next message if uploadPending() above had thrown. // an unrelated next message if uploadPending() above had thrown.
if (_pendingRegenAttachments && _pendingRegenAttachments.length) { if (!approvalForSend && _pendingRegenAttachments && _pendingRegenAttachments.length) {
ids = ids.concat(_pendingRegenAttachments); ids = ids.concat(_pendingRegenAttachments);
} }
_pendingRegenAttachments = null; if (!approvalForSend) _pendingRegenAttachments = null;
// The optimistic user bubble was rendered before the upload assigned ids, // The optimistic user bubble was rendered before the upload assigned ids,
// so image previews couldn't show (the renderer needs att.id). Now that // so image previews couldn't show (the renderer needs att.id). Now that
@@ -1752,14 +1761,50 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
if (activeEmailComposerCtx?.docId) { if (activeEmailComposerCtx?.docId) {
activeDocIdForSend = activeEmailComposerCtx.docId; activeDocIdForSend = activeEmailComposerCtx.docId;
} }
if (documentModule && activeDocIdForSend) { const shouldSaveActiveDoc = !approvalForSend || (
approvalForSend.document_id
&& approvalForSend.document_id === activeDocIdForSend
);
if (documentModule && activeDocIdForSend && shouldSaveActiveDoc) {
try { try {
_sendPerf.mark('doc_save_begin'); _sendPerf.mark('doc_save_begin');
await documentModule.saveDocument(); const documentSaved = await documentModule.saveDocument({
silent: !!approvalForSend,
});
_sendPerf.mark('doc_save_done'); _sendPerf.mark('doc_save_done');
if (approvalForSend && documentSaved === false) {
if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove();
if (
_pendingToolApproval
&& _pendingToolApproval.approval_id === approvalForSend.approval_id
) {
_pendingToolApproval = null;
}
uiModule.showError && uiModule.showError(
'Document could not be saved, so the action was not approved. Reload the chat to retry.'
);
updateSubmitButton('idle', submitBtn);
_releaseSendFlag();
return;
}
} catch(e) { } catch(e) {
console.warn('doc auto-save failed', e); console.warn('doc auto-save failed', e);
_sendPerf.mark('doc_save_failed'); _sendPerf.mark('doc_save_failed');
if (approvalForSend) {
if (_userMsgEl && _userMsgEl.parentNode) _userMsgEl.remove();
if (
_pendingToolApproval
&& _pendingToolApproval.approval_id === approvalForSend.approval_id
) {
_pendingToolApproval = null;
}
uiModule.showError && uiModule.showError(
'Document could not be saved, so the action was not approved. Reload the chat to retry.'
);
updateSubmitButton('idle', submitBtn);
_releaseSendFlag();
return;
}
} }
} }
@@ -1789,23 +1834,30 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
const fd = new FormData(); const fd = new FormData();
fd.append('message', _finalMsgWithInject); fd.append('message', _finalMsgWithInject);
fd.append('session', streamSessionId); fd.append('session', streamSessionId);
if (_pendingToolApproval) { if (approvalForSend) {
fd.append('tool_approval_id', _pendingToolApproval.approval_id); fd.append('tool_approval_id', approvalForSend.approval_id);
fd.append('tool_approval_decision', _pendingToolApproval.decision); fd.append('tool_approval_decision', approvalForSend.decision);
_pendingToolApproval = null; if (
_pendingToolApproval
&& _pendingToolApproval.approval_id === approvalForSend.approval_id
) {
_pendingToolApproval = null;
}
} }
if (selectedRouteForSend.model) fd.append('selected_model', selectedRouteForSend.model); if (selectedRouteForSend.model) fd.append('selected_model', selectedRouteForSend.model);
if (selectedRouteForSend.endpoint_url) fd.append('selected_endpoint_url', selectedRouteForSend.endpoint_url); if (selectedRouteForSend.endpoint_url) fd.append('selected_endpoint_url', selectedRouteForSend.endpoint_url);
if (selectedRouteForSend.endpoint_id) fd.append('selected_endpoint_id', selectedRouteForSend.endpoint_id); if (selectedRouteForSend.endpoint_id) fd.append('selected_endpoint_id', selectedRouteForSend.endpoint_id);
if (ids.length) fd.append('attachments', JSON.stringify(ids)); if (ids.length) fd.append('attachments', JSON.stringify(ids));
// Auto-save & send active doc ID so the backend sees latest content // Auto-save & send active doc ID so the backend sees latest content
if (documentModule && activeDocIdForSend) { if (documentModule && activeDocIdForSend && shouldSaveActiveDoc) {
try { if (!approvalForSend) {
_sendPerf.mark('doc_silent_save_begin'); try {
await documentModule.saveDocument({ silent: true }); _sendPerf.mark('doc_silent_save_begin');
_sendPerf.mark('doc_silent_save_done'); await documentModule.saveDocument({ silent: true });
} catch (_e) { _sendPerf.mark('doc_silent_save_done');
_sendPerf.mark('doc_silent_save_failed'); } catch (_e) {
_sendPerf.mark('doc_silent_save_failed');
}
} }
fd.append('active_doc_id', activeDocIdForSend); fd.append('active_doc_id', activeDocIdForSend);
} }
@@ -1859,7 +1911,7 @@ import { createTerminalStreamError, isRecoverableStreamError } from './chatStrea
if (isAgentMode) { if (isAgentMode) {
fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false'); fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false');
} }
if (el('research-toggle').checked) { if (!approvalForSend && el('research-toggle').checked) {
fd.append('use_research', 'true'); fd.append('use_research', 'true');
// Research always runs in chat mode — override agent if set // Research always runs in chat mode — override agent if set
fd.set('mode', 'chat'); fd.set('mode', 'chat');
+5 -2
View File
@@ -1367,7 +1367,7 @@ document.addEventListener('click', function(e) {
} catch {} } catch {}
}); });
} else if (kind === 'document') { } else if (kind === 'document') {
import('./document.js?v=20260722emailfastindex1').then(mod => { import('./document.js?v=20260815approvalsave1').then(mod => {
const open = mod.loadDocument const open = mod.loadDocument
|| mod.openDocument || mod.openDocument
|| (mod.default && (mod.default.loadDocument || mod.default.openDocument)); || (mod.default && (mod.default.loadDocument || mod.default.openDocument));
@@ -1389,7 +1389,7 @@ document.addEventListener('click', function(e) {
if (open) open(id); if (open) open(id);
}).catch(() => {}); }).catch(() => {});
} else if (kind === 'email') { } else if (kind === 'email') {
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => { import('./emailLibrary.js?v=20260815approvalsave1').then(mod => {
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary); const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (open) open({ uid: id }); if (open) open({ uid: id });
}).catch(() => {}); }).catch(() => {});
@@ -2433,6 +2433,9 @@ export function renderAskUserCard(payload, options) {
approval_id: aq.approval_id, approval_id: aq.approval_id,
decision: String((opt && opt.value) || '').toLowerCase(), decision: String((opt && opt.value) || '').toLowerCase(),
label, label,
document_id: aq.action && aq.action.document_id
? String(aq.action.document_id)
: '',
}, },
})); }));
} else { } else {
+3 -3
View File
@@ -7,7 +7,7 @@ import Storage from './storage.js';
import themeModule from './theme.js'; import themeModule from './theme.js';
import markdownModule from './markdown.js'; import markdownModule from './markdown.js';
import sessionModule from './sessions.js'; import sessionModule from './sessions.js';
import documentModule from './document.js?v=20260722emailfastindex1'; import documentModule from './document.js?v=20260815approvalsave1';
/** /**
* Handle a ui_control SSE event — AI-driven UI manipulation. * Handle a ui_control SSE event — AI-driven UI manipulation.
@@ -156,7 +156,7 @@ export function handleUIControl(uiData) {
if (fn) fn(); if (fn) fn();
}).catch(function(){}); }).catch(function(){});
} else if (panel === 'email') { } else if (panel === 'email') {
import('./emailLibrary.js?v=20260722emailfastindex1').then(function(mod) { import('./emailLibrary.js?v=20260815approvalsave1').then(function(mod) {
var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary); var fn = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (fn) fn(); if (fn) fn();
}).catch(function(){}); }).catch(function(){});
@@ -205,7 +205,7 @@ export function handleUIControl(uiData) {
} catch (e) { } catch (e) {
console.warn('open_email_reply existing draft update failed:', e); console.warn('open_email_reply existing draft update failed:', e);
} }
import('./emailInbox.js?v=20260722emailfastindex1').then(function(mod) { import('./emailInbox.js?v=20260815approvalsave1').then(function(mod) {
var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft); var fn = mod.openReplyDraft || (mod.default && mod.default.openReplyDraft);
if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || ''); if (fn) fn(uiData.uid, uiData.folder || 'INBOX', uiData.mode || 'reply', uiData.body || '');
}).catch(function(e) { }).catch(function(e) {
+7 -5
View File
@@ -3934,7 +3934,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
leadingIcon: 'check', leadingIcon: 'check',
action: 'View Message', action: 'View Message',
onAction: () => { onAction: () => {
import('./emailLibrary.js?v=20260722emailfastindex1').then(mod => { import('./emailLibrary.js?v=20260815approvalsave1').then(mod => {
const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary); const open = mod.openEmailLibrary || (mod.default && mod.default.openEmailLibrary);
if (open) open({ if (open) open({
account_id: data.account_id || activeAccountId || null, account_id: data.account_id || activeAccountId || null,
@@ -9401,9 +9401,9 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
/** Save manual edits */ /** Save manual edits */
export async function saveDocument({ silent = false, forceVersion = false } = {}) { export async function saveDocument({ silent = false, forceVersion = false } = {}) {
if (!activeDocId) return; if (!activeDocId) return false;
const textarea = document.getElementById('doc-editor-textarea'); const textarea = document.getElementById('doc-editor-textarea');
if (!textarea) return; if (!textarea) return false;
const savingDocId = activeDocId; const savingDocId = activeDocId;
saveCurrentToMap(); saveCurrentToMap();
const localDoc = docs.get(savingDocId); const localDoc = docs.get(savingDocId);
@@ -9422,7 +9422,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
}); });
if (res.status === 404) { if (res.status === 404) {
if (silent && localDoc?.language === 'email') { if (silent && localDoc?.language === 'email') {
return; return false;
} }
// Streaming/empty email drafts can leave a local tab pointing at a temp // Streaming/empty email drafts can leave a local tab pointing at a temp
// or already-deleted document. Do not keep surfacing autosave errors for // or already-deleted document. Do not keep surfacing autosave errors for
@@ -9434,7 +9434,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
} }
_syncDocIndicator(); _syncDocIndicator();
if (!silent && uiModule) uiModule.showError('Document no longer exists'); if (!silent && uiModule) uiModule.showError('Document no longer exists');
return; return false;
} }
if (!res.ok) throw new Error(`Document save failed: HTTP ${res.status}`); if (!res.ok) throw new Error(`Document save failed: HTTP ${res.status}`);
const doc = await res.json(); const doc = await res.json();
@@ -9447,6 +9447,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
} }
_syncDocIndicator(); _syncDocIndicator();
if (!silent && uiModule) uiModule.showToast(forceVersion ? 'New version saved' : 'Document saved'); if (!silent && uiModule) uiModule.showToast(forceVersion ? 'New version saved' : 'Document saved');
return true;
} catch (e) { } catch (e) {
console.error('Failed to save document:', e); console.error('Failed to save document:', e);
const now = Date.now(); const now = Date.now();
@@ -9454,6 +9455,7 @@ import { bindMenuDismiss, dismissOrRemove } from './escMenuStack.js';
uiModule.showError(silent ? 'Autosave failed' : 'Failed to save document'); uiModule.showError(silent ? 'Autosave failed' : 'Failed to save document');
_lastAutoSaveErrorAt = now; _lastAutoSaveErrorAt = now;
} }
return false;
} }
} }
+1 -1
View File
@@ -5,7 +5,7 @@
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import sessionModule from './sessions.js'; import sessionModule from './sessions.js';
import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260722emailfastindex1'; import { initEmailLibrary, openEmailLibrary, closeEmailLibrary, isOpen as isLibOpen, prewarmEmailLibrary, prewarmUnreadEmails } from './emailLibrary.js?v=20260815approvalsave1';
import * as Modals from './modalManager.js'; import * as Modals from './modalManager.js';
import { applyEdgeDock } from './modalSnap.js'; import { applyEdgeDock } from './modalSnap.js';
import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js'; import { buildReplyAllCc, extractEmail } from './emailLibrary/replyRecipients.js';
+2 -2
View File
@@ -5,7 +5,7 @@
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import { styledConfirm, showToast, emptyStateIcon } from './ui.js'; import { styledConfirm, showToast, emptyStateIcon } from './ui.js';
import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260722emailfastindex1'; import { folderDisplayName, sortedFolders } from './emailInbox.js?v=20260815approvalsave1';
import settingsModule from './settings.js'; import settingsModule from './settings.js';
import * as Modals from './modalManager.js'; import * as Modals from './modalManager.js';
import { topPortalZ } from './toolWindowZOrder.js'; import { topPortalZ } from './toolWindowZOrder.js';
@@ -6680,7 +6680,7 @@ function _wireAttachmentHandlers(reader, folder) {
ownerModal.classList.add('hidden'); ownerModal.classList.add('hidden');
} }
} }
const docMod = await import('./document.js?v=20260722emailfastindex1'); const docMod = await import('./document.js?v=20260815approvalsave1');
const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument); const load = (docMod && docMod.loadDocument) || (docMod && docMod.default && docMod.default.loadDocument);
if (typeof load === 'function') { if (typeof load === 'function') {
await load(json.doc_id); await load(json.doc_id);
+1 -1
View File
@@ -3,7 +3,7 @@
import Storage from './storage.js'; import Storage from './storage.js';
import uiModule, { autoResize, styledPrompt } from './ui.js'; import uiModule, { autoResize, styledPrompt } from './ui.js';
import chatRenderer from './chatRenderer.js?v=20260815toolapproval3'; import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';
import { providerLogo } from './providers.js'; import { providerLogo } from './providers.js';
import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1'; import { initModelPicker, updateModelPicker } from './modelPicker.js?v=20260722ctxheader1';
import themeModule from './theme.js'; import themeModule from './theme.js';
+1 -1
View File
@@ -2745,7 +2745,7 @@ async function initEmailAccountsSettings() {
el('set-email-open-library-settings')?.addEventListener('click', async () => { el('set-email-open-library-settings')?.addEventListener('click', async () => {
try { try {
const mod = await import('./emailLibrary.js?v=20260722emailfastindex1'); const mod = await import('./emailLibrary.js?v=20260815approvalsave1');
if (typeof mod.openEmailLibrarySettings === 'function') { if (typeof mod.openEmailLibrarySettings === 'function') {
await mod.openEmailLibrarySettings(); await mod.openEmailLibrarySettings();
} }
+1 -1
View File
@@ -16,7 +16,7 @@ import modelsModule from './models.js';
import chatRenderer from './chatRenderer.js'; import chatRenderer from './chatRenderer.js';
import spinnerModule from './spinner.js'; import spinnerModule from './spinner.js';
import themeModule from './theme.js'; import themeModule from './theme.js';
import documentModule from './document.js?v=20260722emailfastindex1'; import documentModule from './document.js?v=20260815approvalsave1';
import workspaceModule from './workspace.js'; import workspaceModule from './workspace.js';
import settingsModule from './settings.js'; import settingsModule from './settings.js';
import cookbookModule from './cookbook.js'; import cookbookModule from './cookbook.js';
+184 -1
View File
@@ -499,6 +499,63 @@ def test_private_manager_write_aliases_keep_write_effect(tool_name, content):
assert capabilities.result_integrity is ResultIntegrity.EXTERNAL_UNTRUSTED assert capabilities.result_integrity is ResultIntegrity.EXTERNAL_UNTRUSTED
@pytest.mark.parametrize(
"tool_name,content",
[
("manage_calendar", '{"action":"delete_event"}'),
("manage_contact", '{"action":"delete"}'),
("manage_documents", '{"action":"tidy"}'),
("manage_endpoints", '{"action":"delete"}'),
("manage_bg_jobs", '{"action":"kill","job_id":"job-1"}'),
("manage_memory", "delete\nmemory-id"),
("manage_mcp", '{"action":"delete"}'),
("manage_notes", '{"action":"delete"}'),
("manage_research", '{"action":"delete"}'),
("manage_session", "truncate\nsession-id\n10"),
("manage_settings", '{"action":"reset","key":"theme"}'),
("manage_skills", '{"action":"delete"}'),
("manage_tasks", '{"action":"delete"}'),
("manage_tokens", '{"action":"delete"}'),
("manage_webhooks", '{"action":"delete"}'),
],
)
def test_multiplexed_destructive_actions_disclose_destructive_effect(
tool_name,
content,
):
capabilities = capabilities_for_action(tool_name, content)
assert any(
effect in capabilities.effects
for effect in (
ToolEffect.WRITE_PRIVATE,
ToolEffect.ADMIN_CHANGE,
ToolEffect.EXECUTE_CODE,
)
)
assert ToolEffect.DESTRUCTIVE in capabilities.effects
@pytest.mark.parametrize(
"tool_name,content",
[
("manage_bg_jobs", '{"action":"output","job_id":"job-1"}'),
("manage_endpoints", '{"action":"list"}'),
("manage_mcp", '{"action":"reconnect"}'),
("manage_settings", '{"action":"set","key":"theme","value":"dark"}'),
("manage_tokens", '{"action":"create","name":"automation"}'),
("manage_webhooks", '{"action":"disable"}'),
],
)
def test_multiplexed_non_destructive_actions_do_not_claim_destructive_effect(
tool_name,
content,
):
capabilities = capabilities_for_action(tool_name, content)
assert ToolEffect.DESTRUCTIVE not in capabilities.effects
def test_ambiguous_private_manager_action_fails_high(): def test_ambiguous_private_manager_action_fails_high():
capabilities = capabilities_for_action("manage_notes", "not json") capabilities = capabilities_for_action("manage_notes", "not json")
@@ -894,6 +951,100 @@ def test_tainted_native_route_keeps_action_schema_for_exact_approval(monkeypatch
assert "update_document" in seen_tools assert "update_document" in seen_tools
def test_tainted_document_edit_without_active_target_cannot_be_approved(monkeypatch):
from src.prompt_security import untrusted_context_message
import src.agent_loop as agent_loop
monkeypatch.setattr(
agent_loop,
"get_setting",
lambda key, default=None: default,
raising=False,
)
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None, raising=False)
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
async def fake_stream(*args, **kwargs):
yield "data: " + json.dumps({
"delta": "```update_document\nreplacement\n```",
}) + "\n\n"
yield "data: [DONE]\n\n"
async def should_not_execute(*args, **kwargs):
raise AssertionError("unsealed document edit reached executor")
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
monkeypatch.setattr(agent_loop, "execute_tool_block", should_not_execute)
events = _collect_agent_events(
agent_loop.stream_agent_loop(
"http://local.test/v1",
"small-local-model",
[
{"role": "user", "content": "update a document"},
untrusted_context_message("stored context", "untrusted"),
],
max_rounds=1,
relevant_tools={"update_document"},
)
)
blocked = [
event
for event in events
if event.get("type") == "tool_output"
and event.get("tool") == "update_document"
]
assert blocked
assert "Open the exact document" in blocked[0]["output"]
assert "ask_user" not in blocked[0]
def test_approval_pause_does_not_trigger_teacher_takeover(monkeypatch):
from src.prompt_security import untrusted_context_message
import src.agent_loop as agent_loop
import src.teacher_escalation as teacher_escalation
monkeypatch.setattr(
agent_loop,
"get_setting",
lambda key, default=None: default,
raising=False,
)
monkeypatch.setattr(agent_loop, "get_mcp_manager", lambda: None, raising=False)
monkeypatch.setattr(agent_loop, "estimate_tokens", lambda *args, **kwargs: 10)
async def fake_stream(*args, **kwargs):
yield "data: " + json.dumps({"delta": "```bash\nprintf paused\n```"}) + "\n\n"
yield "data: [DONE]\n\n"
async def fail_teacher(*args, **kwargs):
raise AssertionError("approval pause reached teacher takeover")
yield # pragma: no cover
monkeypatch.setattr(agent_loop, "stream_llm_with_fallback", fake_stream)
monkeypatch.setattr(teacher_escalation, "run_teacher_inline", fail_teacher)
events = _collect_agent_events(
agent_loop.stream_agent_loop(
"http://local.test/v1",
"small-local-model",
[
{"role": "user", "content": "run it"},
untrusted_context_message("stored context", "untrusted"),
],
session_id="session-1",
max_rounds=1,
relevant_tools={"bash"},
)
)
assert any(
event.get("ask_user", {}).get("kind") == "tool_approval"
for event in events
)
def test_frontend_tool_approval_uses_opaque_id_and_fixed_decisions(): def test_frontend_tool_approval_uses_opaque_id_and_fixed_decisions():
root = Path(__file__).parents[1] root = Path(__file__).parents[1]
chat = (root / "static/js/chat.js").read_text() chat = (root / "static/js/chat.js").read_text()
@@ -910,13 +1061,45 @@ def test_frontend_tool_approval_uses_opaque_id_and_fixed_decisions():
assert "if (isStreaming || _sendInFlight)" in chat assert "if (isStreaming || _sendInFlight)" in chat
assert "_submitToolApprovalWhenIdle" in chat assert "_submitToolApprovalWhenIdle" in chat
assert "input.dispatchEvent(new Event('input'" in chat assert "input.dispatchEvent(new Event('input'" in chat
assert "_pendingToolApproval.draft = input.value" in chat
assert "const approvalForSend = _pendingToolApproval" in chat
assert "!approvalForSend && fileHandlerModule.getPendingCount()" in chat
assert "if (!approvalForSend) _pendingRegenAttachments = null" in chat
assert "!approvalForSend && el('research-toggle').checked" in chat
assert "approvalForSend ? (approvalForSend.draft || '') : ''" in chat
assert "if (approvalForSend && documentSaved === false)" in chat
assert "if (!approvalForSend) {\n try {\n _sendPerf.mark('doc_silent_save_begin')" in chat
assert "document_id: aq.action && aq.action.document_id" in renderer
assert "const firstRound = (toolsByRound[0] || []).length ? 0 : 1" in renderer assert "const firstRound = (toolsByRound[0] || []).length ? 0 : 1" in renderer
assert "const r = ev.round ?? 1" in renderer assert "const r = ev.round ?? 1" in renderer
assert "/test-approval`" in skills assert "/test-approval`" in skills
assert "approval_id: approval.approval_id" in skills assert "approval_id: approval.approval_id" in skills
assert "['approve', 'Allow once'" in skills assert "['approve', 'Allow once'" in skills
assert index.count("app.js?v=20260815toolapproval3") == 2 assert index.count("app.js?v=20260815toolapproval4") == 2
assert "app.js?v=20260808startupshell1" not in index assert "app.js?v=20260808startupshell1" not in index
approval_module_sources = [
(root / path).read_text()
for path in (
"static/app.js",
"static/index.html",
"static/js/chat.js",
"static/js/chatRenderer.js",
"static/js/chatStream.js",
"static/js/document.js",
"static/js/emailInbox.js",
"static/js/emailLibrary.js",
"static/js/settings.js",
"static/js/slashCommands.js",
)
]
assert all(
"20260722emailfastindex1" not in source
for source in approval_module_sources
)
assert all(
"20260815approvalsave1" in source
for source in approval_module_sources
)
def test_frontend_raw_fences_do_not_call_document_mutators(): def test_frontend_raw_fences_do_not_call_document_mutators():
+45
View File
@@ -93,6 +93,7 @@ def _chat_stream_endpoint(
agent_chunks=None, agent_chunks=None,
chat_chunks=None, chat_chunks=None,
capture_completion=False, capture_completion=False,
capture_context=False,
endpoint_url="https://selected.example/v1", endpoint_url="https://selected.example/v1",
): ):
def add_message(message): def add_message(message):
@@ -136,6 +137,8 @@ def _chat_stream_endpoint(
) )
async def fake_build_context(*args, **kwargs): async def fake_build_context(*args, **kwargs):
if capture_context:
captured["build_context"] = kwargs
return context return context
async def fake_chat_stream(candidates, messages, **kwargs): async def fake_chat_stream(candidates, messages, **kwargs):
@@ -336,6 +339,48 @@ async def test_chat_stream_approval_restores_exact_shell_turn_toggle(monkeypatch
assert "bash" not in captured["approval_disabled_tools"] assert "bash" not in captured["approval_disabled_tools"]
@pytest.mark.asyncio
async def test_chat_stream_approval_ignores_research_and_new_attachments(monkeypatch):
from src.tool_capabilities import capabilities_for_action
captured = {}
endpoint = _chat_stream_endpoint(
monkeypatch,
"agent",
captured,
capture_context=True,
)
monkeypatch.setattr(chat_routes, "get_session_mode", lambda _session_id: "research_pending")
pending = chat_routes.tool_approval_store.create(
owner="alice",
session_id="session-1",
origin_run_id="run-1",
tool_name="bash",
content="printf exact",
workspace=None,
external_untrusted_context_seen=True,
capabilities=capabilities_for_action("bash", "printf exact"),
)
request = _RouteRequest("agent")
request._form.update(
{
"attachments": '["unrelated-upload"]',
"use_research": "true",
"tool_approval_id": pending.approval_id,
"tool_approval_decision": "approve",
}
)
response = await endpoint(request)
async for _ in response.body_iterator:
pass
assert captured["exact_approval"].pending == pending
assert captured["build_context"]["att_ids"] == []
assert "agent" in captured
assert "chat" not in captured
@pytest.mark.asyncio @pytest.mark.asyncio
@pytest.mark.parametrize("mode", ["chat", "agent"]) @pytest.mark.parametrize("mode", ["chat", "agent"])
@pytest.mark.parametrize("endpoint_url", ["", None]) @pytest.mark.parametrize("endpoint_url", ["", None])
+46
View File
@@ -12,6 +12,7 @@ Three focused tests:
from __future__ import annotations from __future__ import annotations
import json
from datetime import datetime, timezone from datetime import datetime, timezone
from types import SimpleNamespace from types import SimpleNamespace
@@ -68,6 +69,51 @@ async def test_scheduler_agent_loop_path(monkeypatch):
assert msgs[2]["content"] == "run the digest" assert msgs[2]["content"] == "run the digest"
async def test_scheduler_retires_unattended_exact_approval(monkeypatch):
from src.task_scheduler import TaskScheduler
from src.tool_approvals import tool_approval_store
from src.tool_capabilities import capabilities_for_action
pending = tool_approval_store.create(
owner="admin",
session_id="s",
origin_run_id="scheduled-run",
tool_name="bash",
content="printf exact",
workspace=None,
external_untrusted_context_seen=True,
capabilities=capabilities_for_action("bash", "printf exact"),
)
approval = pending.public_payload()
async def fake_stream_agent_loop(*args, **kwargs):
yield "data: " + json.dumps({
"type": "tool_output",
"tool": "bash",
"output": "Waiting for an exact user approval.",
"ask_user": approval,
}) + "\n\n"
monkeypatch.setattr(
"src.agent_loop.stream_agent_loop",
fake_stream_agent_loop,
)
monkeypatch.setattr(
"src.task_endpoint.resolve_task_candidates",
lambda **kwargs: [],
)
result = await TaskScheduler(session_manager=None)._run_agent_loop(
"http://ep/v1",
"model",
_make_task(),
"s",
)
assert "paused safely" in result
assert "That action was not executed" in result
assert tool_approval_store.peek(pending.approval_id) is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Test 2 — fallback path receives the same datetime context # Test 2 — fallback path receives the same datetime context
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+1 -1
View File
@@ -23,7 +23,7 @@ _IMPORT_REWRITES = {
"import uiModule, { autoResize, styledPrompt } from './ui.js';": ( "import uiModule, { autoResize, styledPrompt } from './ui.js';": (
"import uiModule, { autoResize, styledPrompt } from './ui.mjs';" "import uiModule, { autoResize, styledPrompt } from './ui.mjs';"
), ),
"import chatRenderer from './chatRenderer.js?v=20260815toolapproval3';": ( "import chatRenderer from './chatRenderer.js?v=20260815toolapproval4';": (
"import chatRenderer from './chatRenderer.mjs';" "import chatRenderer from './chatRenderer.mjs';"
), ),
"import { providerLogo } from './providers.js';": ( "import { providerLogo } from './providers.js';": (
+82
View File
@@ -1,4 +1,5 @@
import asyncio import asyncio
import json
from types import SimpleNamespace from types import SimpleNamespace
import pytest import pytest
@@ -217,6 +218,87 @@ async def test_run_teacher_inline_triggers_tier2_escalation(monkeypatch):
assert any("skill_saved" in evt for evt in events) assert any("skill_saved" in evt for evt in events)
@pytest.mark.asyncio
async def test_teacher_approval_keeps_parent_authority_and_skips_skill_save(
monkeypatch,
):
monkeypatch.setattr(
"src.settings.get_setting",
lambda key, default=None: {
"teacher_enabled": True,
"teacher_model": "teacher-model",
}.get(key, default),
)
monkeypatch.setattr(
"src.ai_interaction._resolve_model",
lambda spec, owner=None: (
"http://teacher.local/v1",
"teacher-model",
{},
),
)
monkeypatch.setattr(
"src.teacher_escalation.evaluate_turn_regex",
lambda *args: ("failure", "student failed"),
)
captured = {}
approval = {
"kind": "tool_approval",
"approval_id": "opaque-id",
"question": "Allow this exact action once?",
}
async def fake_stream_agent_loop(*args, **kwargs):
captured.update(kwargs)
yield "data: " + json.dumps({
"type": "tool_output",
"tool": "bash",
"output": "Waiting for an exact user approval.",
"ask_user": approval,
}) + "\n\n"
yield "data: [DONE]\n\n"
async def fail_skill_distillation(*args, **kwargs):
raise AssertionError("paused teacher trace was distilled into a skill")
monkeypatch.setattr(
"src.agent_loop.stream_agent_loop",
fake_stream_agent_loop,
)
monkeypatch.setattr(
"src.teacher_escalation._call_teacher",
fail_skill_distillation,
)
active_document = object()
active_email = {"uid": "email-1"}
policy = object()
events = []
async for evt in teacher_escalation.run_teacher_inline(
student_endpoint_url="http://student.local/v1",
student_messages=[{"role": "user", "content": "test request"}],
student_tool_events=[],
student_reply="student reply",
owner="alice",
session_id="session-1",
workspace="/workspace",
disabled_tools={"web_fetch"},
tool_policy=policy,
active_document=active_document,
active_email=active_email,
):
events.append(evt)
assert captured["session_id"] == "session-1"
assert captured["workspace"] == "/workspace"
assert captured["disabled_tools"] == {"web_fetch"}
assert captured["tool_policy"] is policy
assert captured["active_document"] is active_document
assert captured["active_email"] == active_email
assert any("opaque-id" in event for event in events)
assert not any("skill_saved" in event for event in events)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_teacher_inline_tier2_disabled_by_default(monkeypatch): async def test_run_teacher_inline_tier2_disabled_by_default(monkeypatch):
# Settings and gates (Tier 2 disabled) # Settings and gates (Tier 2 disabled)
+88 -3
View File
@@ -61,10 +61,9 @@ def test_approval_is_bound_to_exact_action_and_claimed_once():
) )
def test_wrong_owner_and_deny_destructively_consume_pending_action(): def test_wrong_owner_cannot_consume_but_deny_retires_pending_action():
store = ToolApprovalStore() store = ToolApprovalStore()
wrong_owner = _pending(store) wrong_owner = _pending(store)
denied = _pending(store)
assert store.consume( assert store.consume(
wrong_owner.approval_id, wrong_owner.approval_id,
@@ -72,7 +71,9 @@ def test_wrong_owner_and_deny_destructively_consume_pending_action():
owner="mallory", owner="mallory",
session_id="session-1", session_id="session-1",
) is None ) is None
assert store.peek(wrong_owner.approval_id) is None assert store.peek(wrong_owner.approval_id) == wrong_owner
denied = _pending(store)
assert store.consume( assert store.consume(
denied.approval_id, denied.approval_id,
decision="deny", decision="deny",
@@ -222,6 +223,48 @@ async def test_dispatcher_uses_sealed_document_target(monkeypatch):
assert captured == [("document-7", 4)] assert captured == [("document-7", 4)]
@pytest.mark.asyncio
async def test_dispatcher_rejects_approved_document_action_without_target(monkeypatch):
import src.tool_execution as tool_execution
store = ToolApprovalStore()
content = "replacement"
pending = _pending(
store,
tool_name="update_document",
content=content,
capabilities=capabilities_for_action("update_document", content),
)
grant = store.consume(
pending.approval_id,
decision="approve",
owner="alice",
session_id="session-1",
)
async def should_not_run(*args, **kwargs):
raise AssertionError("unsealed document target reached implementation")
monkeypatch.setattr(
tool_execution,
"_execute_tool_block_impl",
should_not_run,
)
_, result = await tool_execution.execute_tool_block(
ToolBlock("update_document", content),
session_id="session-1",
owner="alice",
workspace=None,
security_context=ToolRunSecurityContext(
external_untrusted_context_seen=True
),
exact_approval=grant,
)
assert result["blocked"] is True
assert result["policy"] == "exact_tool_approval"
def test_approved_document_version_guard_rejects_changed_target(): def test_approved_document_version_guard_rejects_changed_target():
from src.agent_tools.document_tools import _approved_document_version_error from src.agent_tools.document_tools import _approved_document_version_error
@@ -235,6 +278,48 @@ def test_approved_document_version_guard_rejects_changed_target():
doc, doc,
{"expected_document_version": 5}, {"expected_document_version": 5},
) is None ) is None
assert _approved_document_version_error(
None,
{"expected_document_version": 5},
)["document_changed"] is True
@pytest.mark.asyncio
async def test_missing_sealed_document_does_not_fall_back_to_another(monkeypatch):
import src.agent_tools.document_tools as document_tools
class FakeDb:
def close(self):
pass
def rollback(self):
pass
monkeypatch.setattr("src.database.SessionLocal", lambda: FakeDb())
monkeypatch.setattr(
document_tools,
"_get_owned_document",
lambda *args, **kwargs: None,
)
def fail_fallback(*args, **kwargs):
raise AssertionError("sealed target fell back to a different document")
monkeypatch.setattr(
document_tools,
"_most_recent_owned_document",
fail_fallback,
)
result = await document_tools.UpdateDocumentTool().execute(
"replacement",
{
"doc_id": "deleted-document",
"expected_document_version": 4,
"owner": "alice",
},
)
assert result["document_changed"] is True
@pytest.mark.asyncio @pytest.mark.asyncio