Compare commits

...
Author SHA1 Message Date
4126bea113 fix(gallery): drop the second scrollbar on the photo detail view (#6597)
* fix(gallery): drop the second scrollbar on the photo detail view

With a photo open on desktop, the modal body, the images container and the
detail view are each height: 100%, so the container fills the body exactly.
The container's inline 2px top margin (grid-view spacing) then overflows the
body by 2px, and the body shows its own scrollbar next to the inspector's.
Zero that margin while the detail view is open.

Bumps the shared stylesheet/app cache token and the service worker cache
name, since a stylesheet changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* chore(static): leave admin.js's cache tag unchanged

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cache): keep upstream's service worker cache name format

dev's editor tests require CACHE_NAME to match
odysseus-v<N>-pr40-editor-precache, so keep the version bump and use
that suffix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-02 15:44:29 +00:00
DracieVajkoandMatej f7f6abf82b feat(settings): add a tool approval mode with an explicit off (YOLO) option (#6304)
* feat: add optional tool approval mode

Admin-configurable tool_approval_mode setting (ask/auto) for the
post-external-context tool approval gate.

- ask (default): existing approval flow, gate stays on
- auto: bypasses the approval gate for all runs - same effect as
  ODYSSEUS_TOOL_APPROVAL_GATE=0. Documented honestly in the settings
  row copy and in THREAT_MODEL.md, per review on #6304.
- value validated server-side ({ask, auto}, anything else -> 400)
- no new env var: the setting wires to the existing gate control
- tests: setting validation + _tool_approval_gate_bypassed behavior

* feat: surface deployment gate state on Tool approval row

Follow-up to StressTestor's re-review: when ODYSSEUS_TOOL_APPROVAL_GATE is
set to a falsy value, the Tool approval row still read 'Always ask' even though
the gate was off, misleading admins the other way.

GET /api/auth/settings now returns tool_approval_gate_env_disabled (derived
from TOOL_APPROVAL_GATE_ENABLED) for admins, and the settings row renders a
red notice naming the env var and explaining that the gate is off for every
run regardless of the stored setting. Non-admin/scrubbed responses do not
expose the deployment state. Test covers both env values and the scrub path.

* refactor: rename tool approval mode auto to off

The gate-off value is now "off" ("Off (YOLO)" in the UI) so the auto-approve name stays available for the future judged mode.

---------

Co-authored-by: Matej <matej@local>
2026-11-01 12:22:56 -07:00
Alexandre Teixeira 2a413814c5 fix(chat): honor effective RAG policy in context building (#7025) 2026-11-01 16:14:51 +00:00
Tarık KöşkerandAlexandre Teixeira 1de3c72140 fix(effects): open the effect journal in binary mode on Windows (#6811)
Raw os.open() is text-mode on Windows unless O_BINARY is set, so each
\n written by the journal became \r\n. The log tracks byte offsets,
so the next append read the extra byte as a corrupt tail and every tool
call after the first in a turn failed with 'Effect claim could not be
persisted durably'.

OR O_BINARY (0 on POSIX) into the journal and launch-reservation opens.

Co-authored-by: Alexandre Teixeira <general@alexandreteixeira.dev>
2026-11-01 16:12:42 +00:00
16 changed files with 352 additions and 59 deletions
+1 -1
View File
@@ -64,7 +64,7 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
`src/tool_capabilities.py` carries a second layer: once untrusted content has entered a run, `ToolRunSecurityContext.decision_for()` blocks tools that execute code, mutate state, or cause external side effects until the user authorises the action separately.
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
It is on unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set to a falsy value (`0`/`false`/`no`/`off`). The admin-only `tool_approval_mode` setting (Settings → Agent Tools, `off` = "Off (YOLO)") is the same knob exposed in the UI: with `off`, every run sets `approval_gate_bypassed` and this gate stops asking, exactly as if the env var were off. Request authority narrows which tool families a turn may use, but within an admitted family it does not bind the exact action: a request to read email admits `send_email` and `delete_email`, and agent processes inherit the host network. Until those are covered by their own boundaries, this gate is what stops an injected instruction from reaching them. Turning it off — by either knob — lets a successful injection reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation.
Three exemptions apply even when the gate is on, all deliberate:
+18
View File
@@ -18,6 +18,7 @@ from src.constants import DEEP_RESEARCH_DIR, MEMORY_FILE, PASSWORD_MIN_LENGTH, S
from src.auth_helpers import _auth_disabled
from src.rate_limiter import RateLimiter
from src.settings_scrub import scrub_settings
from src import tool_capabilities
from src.settings import (
load_settings as _load_settings,
save_settings as _save_settings,
@@ -753,6 +754,11 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
user = _get_current_user(request)
settings = without_retired_settings(_load_settings())
if _auth_disabled() or (user and auth_manager.is_admin(user)):
# Deployment-level truth for the Tool approval row: a falsy
# ODYSSEUS_TOOL_APPROVAL_GATE disables the approval gate for every
# run regardless of the stored tool_approval_mode, so surface it to
# admins instead of letting the row mislead them the other way.
settings["tool_approval_gate_env_disabled"] = not tool_capabilities.TOOL_APPROVAL_GATE_ENABLED
return settings
return scrub_settings(settings)
@@ -769,6 +775,9 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
"agent_max_tool_calls": (0, 1000), # 0 = unlimited
"auto_compact_threshold_percent": (50, 95),
}
_VALID_VALUES = {
"tool_approval_mode": {"ask", "off"},
}
for key in DEFAULT_SETTINGS:
if key in RETIRED_SETTING_KEYS:
continue
@@ -782,6 +791,15 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
except (TypeError, ValueError):
raise HTTPException(400, f"{key} must be an integer")
val = max(lo, min(val, hi))
if key in _VALID_VALUES:
allowed = _VALID_VALUES[key]
if not isinstance(val, str) or val not in allowed:
raise HTTPException(
400,
f"{key} must be one of {sorted(allowed)}",
)
current[key] = val
_save_settings(current)
return without_retired_settings(current)
+1 -2
View File
@@ -1276,6 +1276,7 @@ async def build_chat_context(
message=_ctx_msg,
session=sess,
use_web=use_web_val and not skip_web,
use_rag=use_rag_val,
use_memory=mem_enabled,
time_filter=time_filter,
preset_system_prompt=preset.system_prompt,
@@ -1286,8 +1287,6 @@ async def build_chat_context(
incognito=incognito,
use_skills=skills_enabled,
)
if use_rag is not None or is_research_spinoff or casual_low_signal:
_preface_kwargs["use_rag"] = use_rag_val
preface, rag_sources, web_sources = chat_processor.build_context_preface(**_preface_kwargs)
# Capture used memories immediately
+16 -4
View File
@@ -14,12 +14,12 @@ import csv
import difflib
import html
import json
import logging
import os
import re
import shlex
import shutil
import time
import logging
import hashlib
from src.web_recovery import WebRecoveryBudget
from itertools import count
@@ -16994,6 +16994,20 @@ def _detect_runaway_call(call_freq, threshold=15):
sig = next((s for s, n in call_freq.items() if n >= threshold), None)
return sig.split(":", 1)[0] if sig else None
def _tool_approval_gate_bypassed(
exact_approval: Optional[ExactToolApproval] = None,
) -> bool:
"""Return whether the post-external-context tool approval gate is bypassed.
``tool_approval_mode=off`` (admin setting, Settings → Agent Tools) bypasses
the gate on every run, the same effect as ``ODYSSEUS_TOOL_APPROVAL_GATE=0``.
A chat-session grant bypasses it only within the scope of its own task.
"""
return (
get_setting("tool_approval_mode", "ask") == "off"
or bool(exact_approval and exact_approval.allow_remaining_actions)
)
def _tool_result_signature(tool_result_records: list[dict]) -> str:
"""Return a bounded fingerprint of the observable result of a tool batch.
@@ -21437,9 +21451,7 @@ async def stream_agent_loop(
)
or messages_contain_external_untrusted_context(messages)
),
approval_gate_bypassed=bool(
exact_approval and exact_approval.allow_remaining_actions
),
approval_gate_bypassed=_tool_approval_gate_bypassed(exact_approval),
delegated_credential=bool(delegated_credential),
)
_has_tui_host_bridge = _tui_host_bridge_is_usable(client_runtime_context)
+6 -5
View File
@@ -47,6 +47,7 @@ EFFECTS_DIR = os.path.join(DATA_DIR, "effects")
_RUN_ID = re.compile(r"[a-f0-9]{32}")
_TYPES = {"claim": EffectClaim, "outcome": EffectOutcome, "observation": Observation}
_VERSION = 1
_O_BINARY = getattr(os, "O_BINARY", 0) # Windows: raw os.open() is text-mode without it
def _fsync_directory(directory: str | os.PathLike) -> None:
@@ -243,7 +244,7 @@ class EffectLog:
"""Hold the file exclusively with every durable record merged."""
assert self.path is not None
_ensure_directory(self.path.parent)
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
flags = os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
descriptor = os.open(self.path, flags, 0o600)
try:
if fcntl is not None:
@@ -358,7 +359,7 @@ class EffectLog:
with self._lock:
try:
descriptor = os.open(self.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
| getattr(os, "O_CLOEXEC", 0))
| getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
except FileNotFoundError:
return
except OSError:
@@ -394,7 +395,7 @@ class EffectLog:
log = cls(run_id, directory=directory)
assert log.path is not None
try:
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0))
descriptor = os.open(log.path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY)
except FileNotFoundError:
return log
except OSError as error:
@@ -460,7 +461,7 @@ class EffectLog:
temporary = target.with_suffix(".tmp")
data = json.dumps({"run_id": self.run_id, "effect_id": effect_id}, sort_keys=True).encode()
_ensure_directory(target.parent)
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0)
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_CLOEXEC", 0) | _O_BINARY
descriptor = os.open(temporary, flags, 0o600)
try:
view = memoryview(data)
@@ -478,7 +479,7 @@ class EffectLog:
return None
base = Path(directory) if directory is not None else effects_dir()
try:
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0))
descriptor = os.open(base / f"launch-{generation}.json", os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | _O_BINARY)
with os.fdopen(descriptor, "rb") as stream:
if os.fstat(stream.fileno()).st_nlink != 1:
return None
+1
View File
@@ -120,6 +120,7 @@ DEFAULT_SETTINGS = {
# Tune via Settings or by editing data/settings.json.
"research_run_timeout_seconds": 1800,
"agent_max_tool_calls": 0,
"tool_approval_mode": "ask",
"agent_max_rounds": 20, # per-message agent step cap (clamped 1..200)
# Soft input-token budget for the agent loop. The DEFAULT value (6000) is the
# "auto" sentinel: it means "scale the budget to the model's context window"
+7
View File
@@ -339,6 +339,13 @@ mark.doc-find-mark {
height: 100%;
min-height: 0;
}
/* The container now fills the body exactly, so its inline 2px top margin
(spacing for the grid view) would push it 2px past the body and add a
second, outer scrollbar beside the inspector's own. !important is needed
to beat the inline style. */
.gallery-images-container:has(> #gallery-detail[style*="flex"]) {
margin-top: 0 !important;
}
}
.gallery-detail-image:hover .gallery-detail-rotate { opacity: 0.85; }
.gallery-detail-nav-disabled {
+39 -23
View File
@@ -247,32 +247,32 @@
real request is discarded and the font fetched a second time. -->
<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="stylesheet" href="/static/css/00-tokens.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/02-compare.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/04-memory.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/05-documents.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/07-documents.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/08-skills.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/14-email.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/15-notes.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/17-research.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/00-tokens.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/01-agent-chat.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/02-compare.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/03-agent-chat.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/04-memory.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/05-documents.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/06-admin-settings.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/07-documents.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/08-skills.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/09-gallery.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/10-cookbook.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/11-tasks.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/12-gallery.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/13-image-editor.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/14-email.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/15-notes.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/16-calendar.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/17-research.css?v=20261007detailscroll1">
<!-- Panel stylesheets split out of style.css for maintainability. They are
eager and their order is load-bearing: the cascade is the concatenation of
these files in this order, so they must stay in this order here and in the
PRECACHE list in sw.js. None of them is lazy-loaded. -->
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20260929csssplit">
<link rel="modulepreload" href="/static/app.js?v=20260929csssplit">
<link rel="stylesheet" href="/static/css/documents-gallery-editor.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/email-calendar-notes-tasks.css?v=20261007detailscroll1">
<link rel="stylesheet" href="/static/css/cookbook-research-memory-settings.css?v=20261007detailscroll1">
<link rel="modulepreload" href="/static/app.js?v=20261007detailscroll1">
<link rel="modulepreload" href="/static/js/chat.js?v=20260917toolttft1">
<link rel="modulepreload" href="/static/js/ui.js?v=20260916largetoolscroll1">
<link rel="modulepreload" href="/static/js/sessions.js">
@@ -2463,6 +2463,22 @@
<label class="settings-label">Max steps per message</label>
<input id="set-agentMaxRounds" type="text" inputmode="numeric" placeholder="20" class="settings-select" style="width:120px;">
</div>
<div class="settings-row">
<label class="settings-label">Tool approval</label>
<select id="set-toolApprovalMode" class="settings-select">
<option value="ask">Always ask</option>
<option value="off">Off (YOLO)</option>
</select>
</div>
<div class="admin-toggle-sub" style="margin-top:4px;">
Auto-approve turns off the tool approval gate (same control as the
ODYSSEUS_TOOL_APPROVAL_GATE env var). Bash, host_shell, send_email and
delete_email then run without a confirmation even after the agent has
seen untrusted external content (email, web pages, documents), so a
prompt-injected instruction can reach them directly.
</div>
<div id="set-toolApprovalEnvNote" class="admin-toggle-sub" style="margin-top:4px;display:none;color:var(--red);"></div>
<div id="set-agentMsg" style="font-size:11px;color:color-mix(in srgb, var(--fg) 45%, transparent);"></div>
</div>
</div>
@@ -2693,7 +2709,7 @@
<script type="module" src="/static/js/censor.js"></script>
<script type="module" src="/static/js/settings.js?v=20260912writingstyle3"></script>
<script type="module" src="/static/js/assistant.js?v=20260912firefoxjscleanup1"></script>
<script type="module" src="/static/app.js?v=20260929csssplit"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/app.js?v=20261007detailscroll1"></script> <!-- app.js must be LAST -->
<script type="module" src="/static/js/init.js?v=20260829chatstyle12"></script>
<script type="module" src="/static/js/a11y.js"></script>
<script nonce="{{CSP_NONCE}}">if('serviceWorker' in navigator){navigator.serviceWorker.register('/static/sw.js?v=20260916autoscroll1').catch(()=>{});}</script>
+21
View File
@@ -10,6 +10,7 @@ export async function initAgentSettings() {
var toolsInput = el('set-agentMaxTools');
var roundsInput = el('set-agentMaxRounds');
var supInput = el('set-agentSupervisorLadder');
var approvalModeInput = el('set-toolApprovalMode');
var msg = el('set-agentMsg');
if (!toolsInput) return;
@@ -19,6 +20,21 @@ export async function initAgentSettings() {
if (settings.agent_max_tool_calls) toolsInput.value = settings.agent_max_tool_calls;
if (roundsInput && settings.agent_max_rounds) roundsInput.value = settings.agent_max_rounds;
if (supInput) supInput.checked = !!settings.agent_supervisor_ladder;
if (approvalModeInput) {
approvalModeInput.value =
settings.tool_approval_mode === 'off' ? 'off' : 'ask';
}
var approvalEnvNote = el('set-toolApprovalEnvNote');
if (approvalEnvNote) {
if (settings.tool_approval_gate_env_disabled) {
approvalEnvNote.textContent =
'Forced by deployment: ODYSSEUS_TOOL_APPROVAL_GATE is set to a falsy value on this server, so the approval gate is off for every run regardless of this setting.';
approvalEnvNote.style.display = 'block';
} else {
approvalEnvNote.textContent = '';
approvalEnvNote.style.display = 'none';
}
}
} catch (e) {}
// Clamp + coerce a raw input to an int in [lo, hi]; falls back to `dflt`
@@ -37,6 +53,10 @@ export async function initAgentSettings() {
var payload = { agent_max_tool_calls: tools };
if (rounds != null) payload.agent_max_rounds = rounds;
if (supInput) payload.agent_supervisor_ladder = !!supInput.checked;
if (approvalModeInput) {
payload.tool_approval_mode =
approvalModeInput.value === 'off' ? 'off' : 'ask';
}
try {
await _postSettings(payload);
msg.textContent = (tools > 0 ? 'Limit: ' + tools + ' tool calls' : 'Unlimited tool calls') +
@@ -49,6 +69,7 @@ export async function initAgentSettings() {
toolsInput.addEventListener('change', save);
if (roundsInput) roundsInput.addEventListener('change', save);
if (supInput) supInput.addEventListener('change', save);
if (approvalModeInput) approvalModeInput.addEventListener('change', save);
var cur = parseInt(toolsInput.value, 10) || 0;
var curR = roundsInput ? (parseInt(roundsInput.value, 10) || 20) : null;
msg.textContent = (cur > 0 ? 'Limit: ' + cur + ' tool calls' : 'Unlimited tool calls') +
+23 -23
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-v1134-pr40-editor-precache';
const CACHE_NAME = 'odysseus-v1135-pr40-editor-precache';
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
// gives offline math fallback glyphs instead of proper typesetting.
@@ -40,28 +40,28 @@ const KATEX_FONTS = [
const PRECACHE = [
'/static/js/compare/sse.js',
'/',
'/static/css/00-tokens.css?v=20260929csssplit',
'/static/css/01-agent-chat.css?v=20260929csssplit',
'/static/css/02-compare.css?v=20260929csssplit',
'/static/css/03-agent-chat.css?v=20260929csssplit',
'/static/css/04-memory.css?v=20260929csssplit',
'/static/css/05-documents.css?v=20260929csssplit',
'/static/css/06-admin-settings.css?v=20260929csssplit',
'/static/css/07-documents.css?v=20260929csssplit',
'/static/css/08-skills.css?v=20260929csssplit',
'/static/css/09-gallery.css?v=20260929csssplit',
'/static/css/10-cookbook.css?v=20260929csssplit',
'/static/css/11-tasks.css?v=20260929csssplit',
'/static/css/12-gallery.css?v=20260929csssplit',
'/static/css/13-image-editor.css?v=20260929csssplit',
'/static/css/14-email.css?v=20260929csssplit',
'/static/css/15-notes.css?v=20260929csssplit',
'/static/css/16-calendar.css?v=20260929csssplit',
'/static/css/17-research.css?v=20260929csssplit',
'/static/css/documents-gallery-editor.css?v=20260929csssplit',
'/static/css/email-calendar-notes-tasks.css?v=20260929csssplit',
'/static/css/cookbook-research-memory-settings.css?v=20260929csssplit',
'/static/app.js?v=20260929csssplit',
'/static/css/00-tokens.css?v=20261007detailscroll1',
'/static/css/01-agent-chat.css?v=20261007detailscroll1',
'/static/css/02-compare.css?v=20261007detailscroll1',
'/static/css/03-agent-chat.css?v=20261007detailscroll1',
'/static/css/04-memory.css?v=20261007detailscroll1',
'/static/css/05-documents.css?v=20261007detailscroll1',
'/static/css/06-admin-settings.css?v=20261007detailscroll1',
'/static/css/07-documents.css?v=20261007detailscroll1',
'/static/css/08-skills.css?v=20261007detailscroll1',
'/static/css/09-gallery.css?v=20261007detailscroll1',
'/static/css/10-cookbook.css?v=20261007detailscroll1',
'/static/css/11-tasks.css?v=20261007detailscroll1',
'/static/css/12-gallery.css?v=20261007detailscroll1',
'/static/css/13-image-editor.css?v=20261007detailscroll1',
'/static/css/14-email.css?v=20261007detailscroll1',
'/static/css/15-notes.css?v=20261007detailscroll1',
'/static/css/16-calendar.css?v=20261007detailscroll1',
'/static/css/17-research.css?v=20261007detailscroll1',
'/static/css/documents-gallery-editor.css?v=20261007detailscroll1',
'/static/css/email-calendar-notes-tasks.css?v=20261007detailscroll1',
'/static/css/cookbook-research-memory-settings.css?v=20261007detailscroll1',
'/static/app.js?v=20261007detailscroll1',
'/static/js/storage.js',
'/static/js/appConfig.js',
'/static/js/ui.js?v=20260916largetoolscroll1',
+100
View File
@@ -0,0 +1,100 @@
"""Effective RAG policy reaches the real search and context consumer."""
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from src.chat_processor import ChatProcessor
from tests.test_kv_cache_invalidation_2927 import _build_context_harness
pytestmark = pytest.mark.area_routes
@pytest.mark.parametrize('mode', ['chat', 'agent', 'compare'])
@pytest.mark.parametrize('policy', [
'normal', 'incognito', 'preprocessing_disabled', 'research_spinoff', 'low_signal',
])
@pytest.mark.parametrize('use_rag', [None, 'true', 'false', True, False])
async def test_rag_policy_preserves_context_channels(monkeypatch, mode, policy, use_rag):
"""Exercise effective policy with real preface assembly and synthetic I/O.
Nonempty results prove permitted retrieval still injects context. Independent
memory, skills and web spies ensure a RAG disable cannot disable other channels.
"""
from routes import chat_helpers
from src import chat_processor, llm_core, user_time
sess, request, handler, _ = _build_context_harness(monkeypatch, chat_helpers, history=[])
monkeypatch.setattr(chat_helpers, 'load_prefs_for_user', lambda user: {
'memory_enabled': True, 'skills_enabled': True})
monkeypatch.setattr(user_time, 'current_datetime_context_message', lambda: {
'role': 'user', 'content': 'Synthetic current date context'})
incognito = policy == 'incognito'
preprocessing = policy != 'preprocessing_disabled'
if policy == 'research_spinoff':
sess.history = [{'role': 'system', 'metadata': {'research_spinoff_from': 'fixture-report'}}]
message = 'hi' if policy == 'low_signal' else 'Find the retention policy for archived project documents'
search = Mock(return_value=[{
'document': 'Synthetic retention document',
'metadata': {'filename': 'fixture.txt'}, 'similarity': 0.99,
}])
memory = Mock(return_value=[{
'id': 'fixture-memory', 'text': 'User identity synthetic memory',
'category': 'identity', 'pinned': True,
}])
skills = Mock(return_value=[{'name': 'fixture_skill', 'description': 'Synthetic skill'}])
web = Mock(return_value=('Synthetic web context', [{'url': 'https://example.invalid/fixture'}]))
query = Mock(return_value='retention policy')
monkeypatch.setattr(llm_core, 'llm_call', query)
monkeypatch.setattr(chat_processor, 'comprehensive_web_search', web)
processor = ChatProcessor(
memory_manager=SimpleNamespace(load=memory),
personal_docs_manager=SimpleNamespace(rag_manager=SimpleNamespace(search=search)),
skills_manager=SimpleNamespace(index_for=skills),
)
received = []
real_preface = processor.build_context_preface
def capture(**kwargs):
received.append(dict(kwargs))
return real_preface(**kwargs)
monkeypatch.setattr(processor, 'build_context_preface', capture)
ctx = await chat_helpers.build_chat_context(
sess=sess, request=request, chat_handler=handler, chat_processor=processor,
message=message, session_id='c5-policy', use_rag=use_rag,
incognito=incognito, allow_tool_preprocessing=preprocessing,
persist_user_message=False, agent_mode=mode == 'agent',
compare_mode=mode == 'compare', use_web='true',
search_context='Synthetic prefetched context' if mode == 'compare' else None,
)
rag_allowed = policy == 'normal' and str(use_rag).lower() != 'false'
assert len(received) == 1
assert search.call_count == int(rag_allowed)
assert received[0]['use_rag'] is rag_allowed
if rag_allowed:
search.assert_called_once_with(message, k=5, owner='tester')
assert ctx.rag_sources == [{
'filename': 'fixture.txt', 'snippet': 'Synthetic retention document', 'similarity': 0.99,
}]
else:
assert ctx.rag_sources == []
text = '\n'.join(m.get('content', '') for m in ctx.messages)
assert ('Synthetic retention document' in text) is rag_allowed
memory_allowed = policy == 'normal'
assert received[0]['use_memory'] is memory_allowed
assert memory.call_count == int(memory_allowed)
assert ('User identity synthetic memory' in text) is memory_allowed
assert bool(ctx.used_memories) is memory_allowed
skills_allowed = policy in {'normal', 'research_spinoff'}
assert received[0]['use_skills'] is skills_allowed
assert skills.call_count == int(skills_allowed and mode == 'agent')
assert ('fixture_skill' in text) is (skills_allowed and mode == 'agent')
web_allowed = preprocessing and policy != 'low_signal' and mode != 'compare'
assert received[0]['use_web'] is web_allowed
assert web.call_count == query.call_count == int(web_allowed)
assert ('Synthetic web context' in text) is web_allowed
assert bool(ctx.web_sources) is web_allowed
assert ('Synthetic prefetched context' in text) is (
mode == 'compare' and preprocessing and policy != 'low_signal')
+10
View File
@@ -344,3 +344,13 @@ def test_hardlinked_effect_state_is_control_plane_without_scanning_the_store(tmp
elsewhere.write_text("y")
os.link(elsewhere, workspace / "pnpm-style.txt")
assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is True
def test_consecutive_claims_write_lf_only_records(tmp_path, target):
# Raw os.open() is text-mode on Windows unless O_BINARY is set; the \r\n it
# wrote made the next append read a stray empty line as a corrupt tail.
log = EffectLog(RUN, directory=tmp_path / "fx")
claim(log, target, "e1", "act-1")
claim(log, target, "e2", "act-2")
assert b"\r" not in log.path.read_bytes()
assert len(records(log.path)) == 2
+14
View File
@@ -162,6 +162,20 @@ def test_gallery_body_uses_available_window_height_without_grid_crop():
assert "max-height: 60vh;" not in grid_rule
def test_gallery_detail_container_drops_grid_margin_so_body_does_not_scroll():
# With a photo open on desktop, the modal body, the images container and
# the detail view are each `height: 100%`. The container's inline 2px top
# margin (grid-view spacing) would then overflow the body by 2px, which
# shows a second, outer scrollbar next to the inspector's own.
js = _read("static/js/gallery.js")
css = app_css()
assert 'id="gallery-images-container" style="margin-top:2px"' in js
rule = '.gallery-images-container:has(> #gallery-detail[style*="flex"]) {'
idx = css.index(rule)
assert "margin-top: 0 !important;" in css[idx:idx + 120]
def test_gallery_photo_search_is_not_vertically_offset():
css = app_css()
+60
View File
@@ -3,6 +3,7 @@
import asyncio
import json
from types import SimpleNamespace
from fastapi import HTTPException
import pytest
@@ -117,3 +118,62 @@ def test_manage_settings_tombstones_legacy_fallback_key(monkeypatch):
assert save_calls == []
assert store["default_model_fallbacks"] == LEGACY_VALUE
@pytest.mark.asyncio
async def test_tool_approval_mode_accepts_only_supported_values(monkeypatch):
store = dict(settings_mod.DEFAULT_SETTINGS)
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store))
def save_settings(updated):
store.clear()
store.update(updated)
monkeypatch.setattr(auth_routes, "_save_settings", save_settings)
router = auth_routes.setup_auth_routes(_AuthManager())
set_settings = _route(router, "/api/auth/settings", "POST")
response = await set_settings(
_Request({"tool_approval_mode": "off"}, admin=True)
)
assert response["tool_approval_mode"] == "off"
assert store["tool_approval_mode"] == "off"
response = await set_settings(
_Request({"tool_approval_mode": "ask"}, admin=True)
)
assert response["tool_approval_mode"] == "ask"
assert store["tool_approval_mode"] == "ask"
with pytest.raises(HTTPException) as exc_info:
await set_settings(
_Request({"tool_approval_mode": "banana"}, admin=True)
)
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_settings_expose_gate_env_kill_switch_to_admins_only(monkeypatch):
store = dict(settings_mod.DEFAULT_SETTINGS)
monkeypatch.setattr(auth_routes, "migrate_from_settings", lambda: None)
monkeypatch.setattr(auth_routes, "_load_settings", lambda: dict(store))
router = auth_routes.setup_auth_routes(_AuthManager())
get_settings = _route(router, "/api/auth/settings", "GET")
monkeypatch.setattr(auth_routes.tool_capabilities, "TOOL_APPROVAL_GATE_ENABLED", True)
response = await get_settings(_Request(admin=True))
assert response["tool_approval_gate_env_disabled"] is False
monkeypatch.setattr(auth_routes.tool_capabilities, "TOOL_APPROVAL_GATE_ENABLED", False)
response = await get_settings(_Request(admin=True))
assert response["tool_approval_gate_env_disabled"] is True
# The stored setting itself is untouched by the env kill-switch.
assert response["tool_approval_mode"] == "ask"
# Unauthenticated/scrubbed callers must not learn the deployment gate state.
response = await get_settings(_Request())
assert "tool_approval_gate_env_disabled" not in response
+1 -1
View File
@@ -340,7 +340,7 @@ def test_route_context_agent_frontend_and_cache_bust_wire_the_contract():
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 "approval_gate_bypassed=_tool_approval_gate_bypassed(" 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
+34
View File
@@ -500,3 +500,37 @@ async def test_dispatcher_revalidates_sealed_workspace(monkeypatch, tmp_path):
assert result["blocked"] is True
assert result["policy"] == "exact_tool_approval"
def test_tool_approval_mode_defaults_to_ask(monkeypatch):
import src.agent_loop as agent_loop
monkeypatch.setattr(
agent_loop,
"get_setting",
lambda key, default=None: (
"ask" if key == "tool_approval_mode" else default
),
)
assert agent_loop._tool_approval_gate_bypassed() is False
def test_tool_approval_gate_respects_setting(monkeypatch):
import src.agent_loop as agent_loop
monkeypatch.setattr(
agent_loop,
"get_setting",
lambda key, default=None: (
"ask" if key == "tool_approval_mode" else default
),
)
assert agent_loop._tool_approval_gate_bypassed() is False
monkeypatch.setattr(
agent_loop,
"get_setting",
lambda key, default=None: (
"off" if key == "tool_approval_mode" else default
),
)
assert agent_loop._tool_approval_gate_bypassed() is True