mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-15 19:32:21 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bdea753ae | ||
|
|
364229f2dc | ||
|
|
a8c147b238 | ||
|
|
ec5bcd2b9a | ||
|
|
137daab16e | ||
|
|
41cace1337 | ||
|
|
10377f0c95 | ||
|
|
2c8e00ca05 |
+3
-3
@@ -101,9 +101,9 @@ SEARXNG_INSTANCE=http://localhost:8080
|
||||
# Skip the external-context exact-approval pause for unattended local agents.
|
||||
# Keep false for shared or internet-exposed deployments.
|
||||
|
||||
# Post-external-context tool approval gate. On by default; set to 0 only for
|
||||
# trusted single-user setups that accept injected content reaching side-effect tools.
|
||||
# ODYSSEUS_TOOL_APPROVAL_GATE=1
|
||||
# Optional post-external-context tool approval gate. Off by default because it
|
||||
# can block normal agent work; enable only for deployments that want this fence.
|
||||
# ODYSSEUS_TOOL_APPROVAL_GATE=0
|
||||
|
||||
# Mark session cookies Secure. Left unset, this follows the request scheme:
|
||||
# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to
|
||||
|
||||
@@ -147,8 +147,15 @@ jobs:
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
- run: pip install -r requirements.txt
|
||||
# uv resolves the same versions as pip but installs ~5x faster, and its
|
||||
# cache keeps each shard from re-downloading the whole requirement set.
|
||||
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: requirements.txt
|
||||
prune-cache: false
|
||||
- run: uv pip install --system -r requirements.txt
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
@@ -157,15 +164,25 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
# Browser binaries are keyed on the lockfile's Playwright version. On a
|
||||
# hit, --with-deps still installs the system libraries but skips the
|
||||
# browser downloads.
|
||||
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
- run: npx playwright install --with-deps chromium
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
- run: mkdir -p data # sqlite DB lives at ./data/app.db
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
- name: Install FFmpeg for media integration tests
|
||||
# One apt pass for FFmpeg (media integration tests) and bubblewrap
|
||||
# (containment, verified in the next step).
|
||||
- name: Install FFmpeg and bubblewrap
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends ffmpeg
|
||||
sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap
|
||||
command -v ffmpeg
|
||||
ffmpeg -version | head -n 1
|
||||
|
||||
@@ -174,8 +191,6 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends bubblewrap
|
||||
bwrap --version
|
||||
sysctl kernel.unprivileged_userns_clone user.max_user_namespaces \
|
||||
kernel.apparmor_restrict_unprivileged_userns
|
||||
@@ -223,4 +238,4 @@ jobs:
|
||||
if: steps.docs-check.outputs.docs_only != 'true'
|
||||
env:
|
||||
PYTEST_SHARD: ${{ matrix.shard }}
|
||||
run: python -m pytest -q -rs --shard "$PYTEST_SHARD"
|
||||
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
|
||||
|
||||
@@ -53,6 +53,8 @@ services/data/
|
||||
|
||||
# Test capture artifacts (browser session dumps may contain personal content)
|
||||
.playwright-mcp/
|
||||
# Local editor bench/probe artifacts (sessions.json holds session tokens)
|
||||
bench-out/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "vendor/photocraft"]
|
||||
path = vendor/photocraft
|
||||
url = https://github.com/storytold/photocraft
|
||||
+19
-13
@@ -57,10 +57,14 @@ Vendored in `static/lib/` and served directly:
|
||||
|
||||
| Library | Purpose | License |
|
||||
|---|---|---|
|
||||
| [highlight.js](https://github.com/highlightjs/highlight.js) v11.9.0 | Code syntax highlighting | BSD-3-Clause ([full notice](licenses/highlightjs-BSD-3-Clause.txt)) |
|
||||
| [SheetJS / xlsx](https://github.com/SheetJS/sheetjs) v0.20.3 (`xlsx.full.min.js`) | Spreadsheet (`.xlsx`) read/write | Apache-2.0 ([full notice](licenses/SheetJS-Apache-2.0.txt)) |
|
||||
| [docx](https://github.com/dolanmiu/docx) v8.5.0 (`docx.umd.min.js`) | Generate `.docx` documents | MIT and bundled permissive notices ([full notices](licenses/docx-8.5.0-NOTICES.txt)) |
|
||||
| [mammoth.js](https://github.com/mwilliamson/mammoth.js) v1.8.0 | Convert `.docx` → HTML | BSD-2-Clause and bundled permissive notices ([full notices](licenses/mammoth-1.8.0-NOTICES.txt)) |
|
||||
| [highlight.js](https://github.com/highlightjs/highlight.js) v11.9.0 | Code syntax highlighting | BSD-3-Clause |
|
||||
| [SheetJS / xlsx](https://github.com/SheetJS/sheetjs) (`xlsx.full.min.js`) | Spreadsheet (`.xlsx`) read/write | Apache-2.0 |
|
||||
| [docx](https://github.com/dolanmiu/docx) (`docx.umd.min.js`) | Generate `.docx` documents | MIT |
|
||||
| [mammoth.js](https://github.com/mwilliamson/mammoth.js) | Convert `.docx` → HTML | BSD-2-Clause |
|
||||
| [html2pdf.js](https://github.com/eKoopmans/html2pdf.js) | HTML → PDF export (bundles jsPDF + html2canvas) | MIT |
|
||||
| [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT |
|
||||
| [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT |
|
||||
| [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT |
|
||||
| [KaTeX](https://github.com/KaTeX/KaTeX) v0.16.22 (`katex/katex.min.{js,css}` + `katex/fonts/*.woff2`) | Math typesetting | MIT ([`licenses/KaTeX-MIT-LICENSE.txt`](licenses/KaTeX-MIT-LICENSE.txt)) |
|
||||
| [Mermaid](https://github.com/mermaid-js/mermaid) v11.16.1 (`mermaid.min.js`) | Diagrams from text | MIT ([`licenses/Mermaid-MIT-LICENSE.txt`](licenses/Mermaid-MIT-LICENSE.txt)) |
|
||||
|
||||
@@ -72,13 +76,6 @@ browser that supports `woff2`. The bundles are the published npm artifacts,
|
||||
unmodified — `.gitattributes` turns the whitespace check off for `static/lib/`
|
||||
so they can stay byte-identical to upstream.
|
||||
|
||||
Exact artifact hashes, upstream archive members, local filename mappings and
|
||||
notice sources are recorded in [THIRD_PARTY_PROVENANCE.json](THIRD_PARTY_PROVENANCE.json).
|
||||
SheetJS copyright and attribution are preserved in its full distribution license;
|
||||
highlight.js attribution is Copyright 2006 Ivan Sagalaev.
|
||||
Browser printing supplies the client Print / save PDF flow. 2FA QR images are
|
||||
generated by the Python qrcode dependency listed below.
|
||||
|
||||
## Front-end libraries loaded at runtime (CDN)
|
||||
|
||||
Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not vendored:
|
||||
@@ -94,8 +91,9 @@ Bundled in `static/fonts/`:
|
||||
|
||||
| Font | License | Author |
|
||||
|---|---|---|
|
||||
| [Fira Code](https://github.com/tonsky/FiraCode) 6.2 | SIL Open Font License 1.1 ([full notice](licenses/FiraCode-OFL-1.1.txt)) | Nikita Prokopov & contributors |
|
||||
| [Inter](https://github.com/rsms/inter) 4.1 (hinted WOFF2) | SIL Open Font License 1.1 ([full notice](licenses/Inter-OFL-1.1.txt)) | Rasmus Andersson |
|
||||
| [Fira Code](https://github.com/tonsky/FiraCode) | SIL Open Font License 1.1 | Nikita Prokopov & contributors |
|
||||
| [Inter](https://github.com/rsms/inter) | SIL Open Font License 1.1 | Rasmus Andersson |
|
||||
| [GohuFont](https://font.gohu.org/) (`fonts/custom/GohuFont.ttf`) | WTFPL | Hugo Chargois |
|
||||
| [OpenDyslexic](https://opendyslexic.org/) (`fonts/OpenDyslexic-{Regular,Bold}.woff2`) | SIL Open Font License 1.1 ([`licenses/OpenDyslexic-OFL.txt`](licenses/OpenDyslexic-OFL.txt)) | Abbie Gonzalez |
|
||||
|
||||
## Python dependencies
|
||||
@@ -172,4 +170,12 @@ concerns from earlier are resolved:
|
||||
|
||||
## Thanks to
|
||||
|
||||
Most of Odysseus's code was written *with* AI models, not just by a human.
|
||||
The project would not exist without them — credit where credit is due:
|
||||
|
||||
- **gpt-oss-120b** — the legend that kicked this project off.
|
||||
- **Qwen3-235B**
|
||||
- **DeepSeek V3.1 · DeepSeek V4 Pro · DeepSeek V4 Flash**
|
||||
- **Claude** (Anthropic)
|
||||
- **Codex** (OpenAI)
|
||||
- Friends, for helping me debug.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.20.19
|
||||
0.20.84
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ a = Analysis(
|
||||
['launcher.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('licenses', 'licenses'), ('THIRD_PARTY_PROVENANCE.json', '.'), ('ACKNOWLEDGMENTS.md', '.'), ('static', 'static'), ('scripts', 'scripts'), ('mcp_servers', 'mcp_servers'), ('services/hwfit/data', 'services/hwfit/data'), ('config', 'config'), ('.env.example', '.env.example')],
|
||||
datas=[('static', 'static'), ('scripts', 'scripts'), ('mcp_servers', 'mcp_servers'), ('services/hwfit/data', 'services/hwfit/data'), ('config', 'config'), ('.env.example', '.env.example')],
|
||||
hiddenimports=[],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
|
||||
@@ -67,3 +67,24 @@ SAN-191 reconciles references, font preferences, catalogs, tests and packaging.
|
||||
The service-worker cache version changes so activation deletes prior app caches.
|
||||
Omission is not a finding of infringement and does not grant permission to
|
||||
restore the removed originals.
|
||||
|
||||
## Post-publication restoration review
|
||||
|
||||
The historical SAN entries recorded above reflect the conservative posture adopted during publication migration. As noted in SAN-191, omission was not a finding of infringement. A subsequent maintainer review traced these 14 project-origin assets through repository history, recorded their repository provenance, and restored their byte-identical historical versions previously distributed by the project.
|
||||
|
||||
The original SHA-256 hashes are recorded below. Ambiguous third-party assets (such as vendor marks and third-party libraries) remain deliberately omitted.
|
||||
|
||||
- RESTORE-001: `assets/branding/odysseus-browser.jpg` (SHA-256: `96bc894113d3db1a8e19535047e85fef7bfe7e643d45973d6effb945dce667df`): README interface screenshot introduced by pewdiepie-archdaemon in commit "Refresh README screenshot".
|
||||
- RESTORE-002: `assets/branding/odysseus-wordmark.png` (SHA-256: `5e20708f9709735c66692c0cffc74734d61cfa256d48f9095c6870bcd53f4552`): README wordmark artwork introduced by pewdiepie-archdaemon in commit "Refresh README presentation".
|
||||
- RESTORE-003: `assets/branding/odysseus.jpg` (SHA-256: `cd5e87b12f1e9e7baca6a6c5e95012130c9e26c6e31526e864eaf17096fcd05e`): Project branding image from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon, used by build-macos-app.sh for launcher icon generation.
|
||||
- RESTORE-004: `website/bg.webm` (SHA-256: `157f732539a6072d55164da23d65afa9423782728dd067154867fcebb176fb35`): Landing page background video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||
- RESTORE-005: `website/chat.webm` (SHA-256: `d69f319192cec2cac1831e08ee99ae902398f1b163021e42b3fda05a0d7ab4d5`): Chat demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||
- RESTORE-006: `website/compare.webm` (SHA-256: `90d8c68dbc2ac3029b258598a7e2c2c5c8f5d6d702b9d31f0c729e5517093f51`): Model comparison demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||
- RESTORE-007: `website/document.webm` (SHA-256: `754e4ae071e9def5151749a03ad284cd41385354620fc8afea3c5c4f50f64a0d`): Document editor demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||
- RESTORE-008: `website/gallery.webm` (SHA-256: `b3bbe99c55b5cd1de32cb8e84fa31044761b7724a76a4bae4d4b0acaed7f3500`): Image gallery demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||
- RESTORE-009: `website/notes.webm` (SHA-256: `2a82b501271c4b60927e575bb22f8c986e3d1a4089a5518f84b46e534dea37d7`): Notes demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||
- RESTORE-010: `website/research.webm` (SHA-256: `2495a1a6cc1ea037f7460d065e3af6aacf091b240edebc436ce4bca5e83dab87`): Deep research demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||
- RESTORE-011: `website/theme.webm` (SHA-256: `6bd5a4140e9eeb9e06bee217319d331e9c8c873763fcb9040425fb38878b8410`): Theme demo video from commit e5c99a5eee62fc7df0e2dca5e46cbc87399047d6 ("Odysseus v1.0") by pewdiepie-archdaemon.
|
||||
- RESTORE-012: `static/icons/icon-192.png` (SHA-256: `d9f54e07d2a8dca6302125d84436176a26a056745c5adb29ef521345c161e359`): Project PWA icon (192x192) contributed in PR #428 (commit 615134851d5c17897b96bba43b7961a00770d45b), generated from the project logo SVG.
|
||||
- RESTORE-013: `static/icons/icon-512.png` (SHA-256: `785872b140da58087f23539ed187fe0f69de6eb9690abf995b10c93b5b518388`): Project PWA icon (512x512) contributed in PR #428 (commit 615134851d5c17897b96bba43b7961a00770d45b), generated from the project logo SVG.
|
||||
- RESTORE-014: `static/icons/icon-maskable-512.png` (SHA-256: `7ed567fe0de6b6b451eec35c8fb2b465d84a3b23945135c19c4fa388ac976449`): Project PWA maskable icon (512x512) contributed in PR #428 (commit 615134851d5c17897b96bba43b7961a00770d45b), generated from the project logo SVG.
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<h1 align="center">Odysseus</h1>
|
||||
<p align="center">
|
||||
<img src="assets/branding/odysseus-wordmark.png" alt="Odysseus" width="238">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A self-hosted AI workspace for chat, agents, research, documents, email, notes, calendar, and local model workflows.
|
||||
@@ -15,6 +17,10 @@
|
||||
<a href="https://repology.org/project/odysseus-ai/versions"><img src="https://repology.org/badge/vertical-allrepos/odysseus-ai.svg" alt="Packaging status"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/branding/odysseus-browser.jpg" alt="Odysseus interface">
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
@@ -53,7 +59,7 @@ Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration
|
||||
|
||||
## Demo
|
||||
|
||||
The [Odysseus landing page](https://odysseus-dev.github.io/odysseus/) gives a text-only overview of each feature. Its source lives under [`website/`](website/).
|
||||
A full hover-to-play tour lives on the [Odysseus landing page](https://odysseus-dev.github.io/odysseus/). Its source lives under [`website/`](website/).
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
+5
-3
@@ -60,11 +60,13 @@ External content that reaches the LLM is treated as untrusted via `src/prompt_se
|
||||
|
||||
**Untrusted surfaces that must go through this wrapper:** web search results, fetched URLs, emails (read), saved memories, skill text, notes, and any tool output sourced from outside the server. Injecting untrusted content directly into the system role is a security bug.
|
||||
|
||||
### Post-external-context tool approval gate — on by default
|
||||
### Post-external-context tool approval gate — off by default
|
||||
|
||||
`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 disabled unless `ODYSSEUS_TOOL_APPROVAL_GATE` is set** (`1`/`true`/`yes`/`on`). The default is off because the gate is conservative enough to interrupt ordinary agent work. That is a deliberate usability trade, and it means a default deployment relies on the wrapper above — not on the gate — to contain injected instructions.
|
||||
|
||||
Operators who run the agent against untrusted web or email content with side-effecting tools enabled should turn it on. With the gate off, a successful injection can reach `bash`, `host_shell`, `send_email` and `delete_email` without a separate confirmation; with it on, each of those is refused until approved.
|
||||
|
||||
Two exemptions apply even when the gate is on, both deliberate:
|
||||
|
||||
@@ -83,7 +85,7 @@ Two exemptions apply even when the gate is on, both deliberate:
|
||||
|
||||
These are open, acknowledged, and contributor help is welcome:
|
||||
|
||||
1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. The tool approval gate above is the compensating control and is on by default; turning it off leaves this gap unmitigated beyond the untrusted-context wrapper.
|
||||
1. **No shell/filesystem sandbox.** The agent `bash` and `read_file`/`write_file` tools run as the app process user with no network egress filtering or filesystem confinement. A successful prompt-injection reaching a shell-enabled admin session can make outbound requests to internal services. See #1058 for the sandbox proposal. The tool approval gate above is the compensating control, and it is off by default — so on a default deployment this gap is unmitigated beyond the untrusted-context wrapper.
|
||||
|
||||
2. **SSRF via `/api/v1/chat` `base_url` parameter.** A chat-scoped API token can supply an arbitrary `base_url`; the server forwards the LLM request to that host without validating the scheme or address. PR #1039 fixes this.
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 79 KiB |
+18
-2
@@ -27,8 +27,23 @@ echo " port: $PORT"
|
||||
rm -rf "$APP"
|
||||
mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources"
|
||||
|
||||
# Use the macOS default application icon; no branding-derived artwork is bundled.
|
||||
echo " icon: macOS default"
|
||||
# ── Icon (best effort) -- center-crop the branding image to a square .icns ──
|
||||
if [ -f "$REPO_DIR/assets/branding/odysseus.jpg" ] && command -v sips >/dev/null 2>&1; then
|
||||
TMPIMG="$(mktemp -d)"
|
||||
# Center-crop to a square, scale to 512 (sips' icns encoder caps at 512), and
|
||||
# let sips emit the .icns directly: more robust across macOS versions than
|
||||
# building an .iconset by hand.
|
||||
sips -c 720 720 "$REPO_DIR/assets/branding/odysseus.jpg" --out "$TMPIMG/sq.png" >/dev/null 2>&1 || cp "$REPO_DIR/assets/branding/odysseus.jpg" "$TMPIMG/sq.png"
|
||||
sips -z 512 512 "$TMPIMG/sq.png" --out "$TMPIMG/icon.png" >/dev/null 2>&1
|
||||
if sips -s format icns "$TMPIMG/icon.png" --out "$APP/Contents/Resources/odysseus.icns" >/dev/null 2>&1; then
|
||||
echo " icon: odysseus.icns"
|
||||
else
|
||||
echo " icon: (skipped - conversion failed)"
|
||||
fi
|
||||
rm -rf "$TMPIMG"
|
||||
else
|
||||
echo " icon: (skipped - no assets/branding/odysseus.jpg)"
|
||||
fi
|
||||
cp -R "$REPO_DIR/licenses" "$APP/Contents/Resources/licenses"
|
||||
cp "$REPO_DIR/THIRD_PARTY_PROVENANCE.json" "$REPO_DIR/ACKNOWLEDGMENTS.md" "$APP/Contents/Resources/"
|
||||
|
||||
@@ -45,6 +60,7 @@ cat > "$APP/Contents/Info.plist" <<PLIST
|
||||
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||
<key>CFBundlePackageType</key> <string>APPL</string>
|
||||
<key>CFBundleExecutable</key> <string>$APP_NAME</string>
|
||||
<key>CFBundleIconFile</key> <string>odysseus</string>
|
||||
<key>LSMinimumSystemVersion</key> <string>11.0</string>
|
||||
<key>NSHighResolutionCapable</key> <true/>
|
||||
<key>LSUIElement</key> <false/>
|
||||
|
||||
@@ -11,8 +11,6 @@ from typing import Dict, List, Any, Optional, TYPE_CHECKING
|
||||
from src.tool_approval_scopes import (
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
||||
CHAT_SESSION_APPROVAL_DECISION,
|
||||
CHAT_SESSION_APPROVAL_SIGNATURE_FIELD,
|
||||
verify_chat_session_grant,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -62,14 +60,6 @@ def _history_grants_chat_session_approval(
|
||||
ask_user.get("kind") == "tool_approval"
|
||||
and ask_user.get("resolved") == CHAT_SESSION_APPROVAL_DECISION
|
||||
and str(ask_user.get("session_id") or "") == expected_session
|
||||
# Shape proves nothing here: routes that accept a
|
||||
# caller-supplied metadata blob write into this same history.
|
||||
and verify_chat_session_grant(
|
||||
ask_user.get(CHAT_SESSION_APPROVAL_SIGNATURE_FIELD),
|
||||
expected_session,
|
||||
ask_user.get("approval_id"),
|
||||
CHAT_SESSION_APPROVAL_DECISION,
|
||||
)
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
+37
-37
@@ -1185,6 +1185,9 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
|
||||
"subject": subject,
|
||||
"from": sender_name or sender_addr or sender,
|
||||
"from_address": sender_addr,
|
||||
"to": str(row.get("to") or headers.get("To") or ""),
|
||||
"cc": str(row.get("cc") or headers.get("Cc") or ""),
|
||||
"references": str(row.get("references") or headers.get("References") or ""),
|
||||
"date": date_str,
|
||||
"date_epoch": date_epoch,
|
||||
"summary": body[:240],
|
||||
@@ -1198,7 +1201,7 @@ def _fixture_email_record(row: dict, uid_num: int, owner: str) -> dict:
|
||||
"folder": folder,
|
||||
"is_read": bool(row.get("read")),
|
||||
"is_done": bool(row.get("done") or row.get("answered")),
|
||||
"is_favorite": bool(row.get("favorite") or row.get("flagged") or row.get("starred")),
|
||||
"is_favorite": bool(row.get("favorite", row.get("flagged") or row.get("starred"))),
|
||||
"spam_label": str(row.get("spam_label") or ""),
|
||||
"spam_score": int(row.get("spam_score") or 0),
|
||||
"list_unsubscribe": str(row.get("list_unsubscribe") or headers.get("List-Unsubscribe") or ""),
|
||||
@@ -1522,15 +1525,17 @@ def _fixture_read_email(uid=None, message_id=None, folder="INBOX", account=None)
|
||||
return None
|
||||
if not _fixture_owner_has_rows(_current_owner()):
|
||||
return None
|
||||
for item in _fixture_email_rows(_current_owner()):
|
||||
if not _fixture_row_matches_account(item, account):
|
||||
continue
|
||||
if not _fixture_folder_matches(item.get("folder"), folder):
|
||||
continue
|
||||
if uid and str(item.get("uid")) == str(uid):
|
||||
return item
|
||||
if message_id and str(item.get("message_id")) == str(message_id):
|
||||
return item
|
||||
if not uid and not message_id:
|
||||
return {"error": "Email UID or Message-ID is required"}
|
||||
matches = [item for item in _fixture_email_rows(_current_owner())
|
||||
if _fixture_row_matches_account(item, account)
|
||||
and _fixture_folder_matches(item.get("folder"), folder)
|
||||
and (not uid or str(item.get("uid")) == str(uid))
|
||||
and (not message_id or str(item.get("message_id")) == str(message_id))]
|
||||
if len(matches) > 1:
|
||||
return {"error": "Email identity is ambiguous; specify its account and folder"}
|
||||
if matches:
|
||||
return matches[0]
|
||||
return {"error": f"Email not found with UID/Message-ID: {uid or message_id}"}
|
||||
|
||||
|
||||
@@ -1557,6 +1562,7 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
|
||||
if not isinstance(rows, list):
|
||||
return False
|
||||
owner = _current_owner()
|
||||
matches = []
|
||||
for index, row in enumerate(rows, start=1):
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
@@ -1568,14 +1574,19 @@ def _fixture_update_email(uid=None, source_folder="INBOX", account=None, **updat
|
||||
continue
|
||||
if not _fixture_folder_matches(row.get("folder") or "INBOX", source_folder):
|
||||
continue
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
row.pop(key, None)
|
||||
else:
|
||||
row[key] = value
|
||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return True
|
||||
return False
|
||||
if not _fixture_row_matches_account(_fixture_email_record(row, index, owner or row_owner), account):
|
||||
continue
|
||||
matches.append(row)
|
||||
if len(matches) != 1:
|
||||
return False
|
||||
row = matches[0]
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
row.pop(key, None)
|
||||
else:
|
||||
row[key] = value
|
||||
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
# ── Tool implementations ──
|
||||
@@ -2925,7 +2936,9 @@ def _create_email_draft_document(
|
||||
|
||||
def _draft_reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None, title=None):
|
||||
"""Create a threaded Odysseus reply draft document. Does not send."""
|
||||
fixture = _fixture_email_action_target(uid=uid, folder=folder, account=account)
|
||||
fixture = _fixture_read_email(uid=uid, folder=folder, account=account)
|
||||
if _fixture_email_enabled() and (fixture is None or fixture.get("error")):
|
||||
return fixture or {"error": "No email fixture is available for the authenticated owner"}
|
||||
if fixture is not None:
|
||||
sender = str(fixture.get("from_address") or fixture.get("from") or "")
|
||||
_, sender_addr = email.utils.parseaddr(sender)
|
||||
@@ -3178,7 +3191,9 @@ def _reply_to_email(uid, body, folder="INBOX", reply_all=False, account=None):
|
||||
|
||||
def _set_flag(uid, folder, flag, add=True, account=None):
|
||||
"""Add or remove an IMAP flag (e.g. \\Seen, \\Answered, \\Deleted)."""
|
||||
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is not None:
|
||||
if _fixture_email_enabled():
|
||||
if _fixture_email_action_target(uid=uid, folder=folder, account=account) is None:
|
||||
return False
|
||||
if flag == "\\Seen":
|
||||
return _fixture_update_email(uid=uid, source_folder=folder, account=account, read=bool(add))
|
||||
if flag == "\\Answered":
|
||||
@@ -3504,21 +3519,6 @@ def _block_sender(sender=None, uids=None, folder="INBOX", account=None, reason="
|
||||
}
|
||||
|
||||
|
||||
def _attachment_dir(folder, uid, account_id) -> Path:
|
||||
"""Owner/account-scoped extraction directory (see src.mail_attachment_paths).
|
||||
|
||||
`folder` comes from tool arguments and the IMAP server's own mailbox names
|
||||
(`/` hierarchies, absolute or `..` segments), so it must not become a path;
|
||||
the old `MAIL_ATTACHMENTS_DIR/{folder}_{uid}` wrote outside the root and
|
||||
shared one directory between every owner's "INBOX 42".
|
||||
"""
|
||||
from src.mail_attachment_paths import attachment_scope_dir
|
||||
|
||||
return attachment_scope_dir(
|
||||
MAIL_ATTACHMENTS_DIR, folder, uid, owner=_current_owner(), account_id=account_id,
|
||||
)
|
||||
|
||||
|
||||
def _download_attachment(uid, index, folder="INBOX", account=None):
|
||||
"""Extract a specific attachment to disk and return its local path."""
|
||||
fixture = _fixture_attachment_source(uid, index, folder=folder, account=account)
|
||||
@@ -3527,7 +3527,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None):
|
||||
filename = str(att.get("filename") or f"attachment-{index}.txt")
|
||||
safe_name = re.sub(r"[^\w\s\-.]", "_", filename).strip() or f"attachment-{index}.txt"
|
||||
content = str(att.get("content") or "")
|
||||
target_dir = _attachment_dir(folder, uid, _row.get("account_id") or account)
|
||||
target_dir = Path(MAIL_ATTACHMENTS_DIR) / re.sub(r"[^A-Za-z0-9._-]", "_", f"{folder}_{uid}")
|
||||
path = target_dir / safe_name
|
||||
size = len(content.encode("utf-8"))
|
||||
try:
|
||||
@@ -3560,7 +3560,7 @@ def _download_attachment(uid, index, folder="INBOX", account=None):
|
||||
raw = msg_data[0][1]
|
||||
msg = email.message_from_bytes(raw)
|
||||
|
||||
target_dir = _attachment_dir(folder, uid, _load_config(account).get("account_id") or account)
|
||||
target_dir = Path(MAIL_ATTACHMENTS_DIR) / f"{folder}_{uid}"
|
||||
filepath = _extract_attachment_to_disk(msg, index, target_dir)
|
||||
if not filepath:
|
||||
return {"error": f"Attachment index {index} not found"}
|
||||
|
||||
@@ -16,7 +16,6 @@ from pydantic import BaseModel
|
||||
|
||||
from core.database import SessionLocal, CrewMember, ScheduledTask
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.endpoint_resolver import resolve_owner_registered_endpoint_url
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||
from src.task_scheduler import compute_next_run
|
||||
|
||||
@@ -179,13 +178,7 @@ def setup_assistant_routes(task_scheduler) -> APIRouter:
|
||||
if payload.model is not None:
|
||||
crew_db.model = payload.model or None
|
||||
if payload.endpoint_url is not None:
|
||||
try:
|
||||
crew_db.endpoint_url = (
|
||||
resolve_owner_registered_endpoint_url(db, payload.endpoint_url, owner)
|
||||
if payload.endpoint_url else None
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
crew_db.endpoint_url = payload.endpoint_url or None
|
||||
if payload.timezone is not None:
|
||||
crew_db.timezone = payload.timezone or None
|
||||
|
||||
|
||||
@@ -409,7 +409,7 @@ def parse_due_for_user(s: str) -> str:
|
||||
lower = s.lower().strip()
|
||||
|
||||
def _parse_time(t):
|
||||
t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
||||
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
||||
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
||||
if not m: return None
|
||||
h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower()
|
||||
@@ -433,7 +433,7 @@ def parse_due_for_user(s: str) -> str:
|
||||
return base.replace(hour=t[0], minute=t[1]).isoformat()
|
||||
|
||||
# Time-first: "3pm today", "11pm today", "9am tomorrow"
|
||||
m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
|
||||
m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
|
||||
if m:
|
||||
time_part, word = m.group(1).strip(), m.group(2)
|
||||
base = today
|
||||
@@ -530,7 +530,7 @@ def _parse_dt(s: str) -> datetime:
|
||||
|
||||
def _parse_time(t: str):
|
||||
"""Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None."""
|
||||
t = _re.sub(r'\b([ap])(?:\s*\.)?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
||||
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
|
||||
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
|
||||
if not m:
|
||||
return None
|
||||
@@ -562,7 +562,7 @@ def _parse_dt(s: str) -> datetime:
|
||||
|
||||
# time-first: "3pm today", "9am tomorrow", "11pm tonight"
|
||||
# (parity with parse_due_for_user, which handles these via the same form)
|
||||
m = _re.match(r'^(.*\S)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
|
||||
m = _re.match(r'^(.+?)\s+(today|tonight|tomorrow|tmrw|yesterday)$', lower)
|
||||
if m:
|
||||
time_part, word = m.group(1).strip(), m.group(2)
|
||||
base = today
|
||||
|
||||
@@ -98,9 +98,7 @@ def clean_repeated_assistant_content(text: object) -> str:
|
||||
"",
|
||||
value,
|
||||
).strip()
|
||||
# No leading `\s*`: .strip() removes that whitespace anyway, and scanning
|
||||
# it from every offset of a long whitespace run was quadratic (ReDoS).
|
||||
value = re.sub(r"(?is)</\s*think\s*>\s*$", "", value).strip()
|
||||
value = re.sub(r"(?is)\s*</\s*think\s*>\s*$", "", value).strip()
|
||||
return value
|
||||
|
||||
_CASUAL_OPENING_RE = re.compile(
|
||||
@@ -1326,17 +1324,13 @@ def _normalize_thinking(text: str) -> str:
|
||||
|
||||
# Handle garbled <think> tags: reasoning text followed by <think> as separator
|
||||
# e.g. "The user said...I should respond.\n<think>Hey! What's up?"
|
||||
# Linear form of `^([\s\S]+?)\n*<think>\s*([\s\S]*?)(?:</think>)?\s*$`:
|
||||
# the lookbehind stops the lazy prefix re-scanning a newline run from every
|
||||
# offset, and the optional trailing closer is dropped after the match
|
||||
# instead of being retried at every body offset (both were quadratic).
|
||||
garbled = re.match(
|
||||
r'^([\s\S]+?)(?<![\s\S]\n)\n*<think(?:ing)?>\s*([\s\S]*)$',
|
||||
r'^([\s\S]+?)\n*<think(?:ing)?>\s*([\s\S]*?)(?:</think(?:ing)?>)?\s*$',
|
||||
text, re.IGNORECASE
|
||||
)
|
||||
if garbled:
|
||||
before = garbled.group(1).strip()
|
||||
after = re.sub(r'</think(?:ing)?>$', '', garbled.group(2).rstrip(), flags=re.IGNORECASE).strip()
|
||||
after = garbled.group(2).strip()
|
||||
# Only treat as garbled if the part before <think> looks like reasoning
|
||||
reasoning_starts = (
|
||||
'The user ', 'I need ', 'I should ', 'I will ',
|
||||
|
||||
+116
-81
@@ -11,7 +11,7 @@ from urllib.parse import urlparse
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any, AsyncGenerator, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Form, Query, Depends
|
||||
from fastapi import APIRouter, Request, HTTPException, Form, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import ValidationError
|
||||
|
||||
@@ -32,6 +32,7 @@ from src.agent_loop import (
|
||||
)
|
||||
from src.agent_loop import _normalize_ody_qwen_text_artifacts
|
||||
from src import agent_runs
|
||||
from src import shutdown as _shutdown
|
||||
from src.model_context import estimate_tokens
|
||||
from src.context_compactor import (
|
||||
apply_compaction_state,
|
||||
@@ -48,13 +49,7 @@ from src.foreground_model_routing import (
|
||||
from src.session_search import search_session_messages
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from core.exceptions import SessionNotFoundError
|
||||
from src.auth_helpers import (
|
||||
effective_user,
|
||||
get_current_user,
|
||||
is_delegated_credential,
|
||||
require_api_token_scope,
|
||||
require_chat_api_token_scope,
|
||||
)
|
||||
from src.auth_helpers import effective_user, get_current_user
|
||||
from routes.session_routes import _verify_session_owner
|
||||
from routes.document_helpers import _owner_session_filter
|
||||
from core.database import SessionLocal, get_session_mode, set_session_mode
|
||||
@@ -85,8 +80,6 @@ from src.tool_policy import (
|
||||
web_search_enabled_for_turn,
|
||||
)
|
||||
from src.tool_approvals import tool_approval_store
|
||||
from src.tool_approval_scopes import stamp_chat_session_grant
|
||||
from src.tool_security import delegated_credential_blocked_tools
|
||||
from src.workspace_paths import backend_workspace_path
|
||||
from src.client_tool_contract import TUI_CLIENT_TOOL_NAMES
|
||||
from src.model_profiles import (
|
||||
@@ -327,31 +320,13 @@ def _is_external_discovery_request(text: str) -> bool:
|
||||
))
|
||||
|
||||
|
||||
_URL_SCHEME_RE = re.compile(r"https?://", re.I)
|
||||
_PDF_URL_TAIL_RE = re.compile(r"\.pdf\b|/pdf/", re.I)
|
||||
|
||||
|
||||
def _mentions_pdf_url(value: str) -> bool:
|
||||
"""Same as ``re.search(r"https?://[^\\s]+(?:\\.pdf\\b|/pdf/)", value, re.I)``.
|
||||
|
||||
Checked per whitespace-free token from its first scheme only (a later
|
||||
scheme's tail is a suffix of the first's), so a token packed with
|
||||
`http://` repeats is scanned once instead of once per repeat (ReDoS).
|
||||
"""
|
||||
for token in value.split():
|
||||
scheme = _URL_SCHEME_RE.search(token)
|
||||
if scheme and _PDF_URL_TAIL_RE.search(token, scheme.end() + 1):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _prefers_structured_document_tools(text: str) -> bool:
|
||||
"""Identify external paper/PDF extraction where shell is a bad source route."""
|
||||
value = str(text or "")
|
||||
if re.search(r"(?:^|\s)(?:file://)?/workspace/[^\s`\"']+\.pdf\b", value, re.I):
|
||||
return False
|
||||
return bool(
|
||||
_mentions_pdf_url(value)
|
||||
re.search(r"https?://[^\s]+(?:\.pdf\b|/pdf/)", value, re.I)
|
||||
or re.search(
|
||||
r"\b(?:paper|report|study)\b[\s\S]{0,1200}?"
|
||||
r"\b(?:tables?|figures?|benchmarks?|scores?|metrics?)\b",
|
||||
@@ -1290,23 +1265,6 @@ def _stream_failure_status(chunk: str) -> Optional[int]:
|
||||
return None
|
||||
|
||||
|
||||
def _reject_delegated_tool_approval(request: Request) -> None:
|
||||
"""Refuse an approval answered by a bearer API token.
|
||||
|
||||
A tool approval records that a HUMAN authorized one dangerous action. A
|
||||
token is a delegated credential handed to an integration, so when it
|
||||
answers the prompt it triggered, nobody is asked and the gate collapses
|
||||
into an extra round trip. Owner and session already match here: the token
|
||||
is answering on behalf of the account that minted it.
|
||||
"""
|
||||
if is_delegated_credential(request):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"Tool approvals require an interactive session. "
|
||||
"API tokens cannot authorize a gated action.",
|
||||
)
|
||||
|
||||
|
||||
def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
|
||||
"""Persist a consumed approval decision on its existing tool event."""
|
||||
|
||||
@@ -1331,11 +1289,6 @@ def _mark_tool_approval_resolved(sess, approval_id: Any, decision: Any) -> bool:
|
||||
if str(ask_user.get("approval_id") or "") != approval_key:
|
||||
continue
|
||||
ask_user["resolved"] = normalized_decision
|
||||
stamp_chat_session_grant(
|
||||
ask_user,
|
||||
getattr(sess, "id", ""),
|
||||
normalized_decision,
|
||||
)
|
||||
message_id = metadata.get("_db_id")
|
||||
resolved_metadata = {
|
||||
key: value for key, value in metadata.items() if key != "_db_id"
|
||||
@@ -2178,17 +2131,13 @@ def setup_chat_routes(
|
||||
webhook_manager=None,
|
||||
skills_manager=None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(
|
||||
tags=["chat"],
|
||||
dependencies=[Depends(require_chat_api_token_scope)],
|
||||
)
|
||||
router = APIRouter(tags=["chat"])
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# POST /api/chat (non-streaming)
|
||||
# ------------------------------------------------------------------ #
|
||||
@router.post("/api/chat", response_model=Dict[str, Any])
|
||||
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, Any]:
|
||||
require_api_token_scope(request, "chat")
|
||||
_set_user_time_from_request(request)
|
||||
|
||||
message = chat_request.message
|
||||
@@ -2405,7 +2354,6 @@ def setup_chat_routes(
|
||||
# ------------------------------------------------------------------ #
|
||||
@router.post("/api/chat_stream")
|
||||
async def chat_stream(request: Request) -> StreamingResponse:
|
||||
require_api_token_scope(request, "chat")
|
||||
body = None
|
||||
try:
|
||||
if request.headers.get("content-type", "").startswith("application/json"):
|
||||
@@ -2525,7 +2473,7 @@ def setup_chat_routes(
|
||||
_explicit_web_intent = (
|
||||
_explicit_url_target
|
||||
or bool(re.search(
|
||||
r"\b(search|look\s+(?:(?:this|that|it|them|these|those)\s*)?up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b",
|
||||
r"\b(search|look\s+(?:this|that|it|them|these|those)?\s*up|lookup|find\s*out|google|browse|web|online|latest|current|today|news|weather|forecast|rate|exchange\s+rate)\b",
|
||||
_msg_l,
|
||||
))
|
||||
or requires_external_web_verification(message)
|
||||
@@ -2624,6 +2572,21 @@ def setup_chat_routes(
|
||||
logger.info("chat→agent auto-escalation: explicit private browser workflow")
|
||||
active_doc_id = form_data.get("active_doc_id", "").strip()
|
||||
active_doc_state = form_data.get("active_doc_state", "").strip().casefold()
|
||||
# Scoped photo_editor agent context: the Quick Edit popup (and any
|
||||
# image-edit chat turn) may carry the open image-editor session id and a
|
||||
# canvas snapshot so the editor tool family can drive it in-process.
|
||||
editor_session = (
|
||||
form_data.get("editor_session")
|
||||
or form_data.get("editor_session_id")
|
||||
or ""
|
||||
).strip()
|
||||
editor_state = form_data.get("editor_state") or ""
|
||||
editor_screenshot = form_data.get("editor_screenshot") or ""
|
||||
photo_editor_requested = (
|
||||
str(form_data.get("photo_editor") or "").strip().lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
or bool(editor_session)
|
||||
)
|
||||
logger.info(
|
||||
"[doc-inject] chat_mode=%s, active_doc_id=%r, active_doc_state=%r",
|
||||
chat_mode, active_doc_id, active_doc_state,
|
||||
@@ -2731,7 +2694,6 @@ def setup_chat_routes(
|
||||
)
|
||||
owner = effective_user(request)
|
||||
if tool_approval_id:
|
||||
_reject_delegated_tool_approval(request)
|
||||
from src.agent_runtime.authority import require_user_approval_request
|
||||
require_user_approval_request(request)
|
||||
pending_tool_approval = tool_approval_store.peek(tool_approval_id)
|
||||
@@ -3189,7 +3151,38 @@ def setup_chat_routes(
|
||||
active_document=bool(active_doc), workspace=bool(workspace),
|
||||
image_attachment=any(str(a.get('mime') or '').startswith('image/') for a in (ctx.preprocessed.attachment_meta or [])),
|
||||
) if _use_turn_contract else frozenset()
|
||||
if 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
|
||||
# Scoped photo_editor family: offered ONLY for an explicit editor
|
||||
# request (Quick Edit popup / editor session) or an image-edit chat
|
||||
# turn carrying an image. When active the turn offers just the editor
|
||||
# tools plus ask_user, never the general tool list.
|
||||
_photo_editor_active = bool(_use_turn_contract) and (
|
||||
photo_editor_requested
|
||||
or (
|
||||
"image_editing" in _turn_capabilities
|
||||
and bool(ctx.preprocessed.attachment_meta)
|
||||
)
|
||||
)
|
||||
if _photo_editor_active:
|
||||
# The editor tool family drives the browser-side image editor; it
|
||||
# replaces the generic edit_image instruction. Without an open
|
||||
# editor session the tools cannot reach the canvas, so tell the
|
||||
# user to open the image instead of emitting failing tool calls.
|
||||
if editor_session:
|
||||
editor_context = {'role': 'system', 'content':
|
||||
'The image editor is open (session ' + editor_session + '). Use run_editor_command, '
|
||||
'plan_edit and get_editor_screenshot to inspect and change the current canvas, and '
|
||||
'ai_edit_image for model-based edits. Apply the user\'s requested edit to the open '
|
||||
'editor and confirm the result with a screenshot.'}
|
||||
else:
|
||||
editor_context = {'role': 'system', 'content':
|
||||
'The user attached an image but no image editor is open, so the editor tools cannot '
|
||||
'reach the canvas. Do not call editor tools. Reply briefly telling the user to open '
|
||||
'the attached image in the editor (Quick Edit) and repeat the request; the editor '
|
||||
'tools will then apply it.'}
|
||||
ctx.messages.insert(0, editor_context)
|
||||
if foreground_policy.enabled:
|
||||
getattr(ctx, 'route_messages', ctx.messages).insert(0, dict(editor_context))
|
||||
elif 'image_editing' in _turn_capabilities and ctx.preprocessed.attachment_meta:
|
||||
image_refs = ['odysseus://attachment/' + str(a['id']) for a in ctx.preprocessed.attachment_meta
|
||||
if a.get('id') and str(a.get('mime') or '').startswith('image/')]
|
||||
if image_refs:
|
||||
@@ -3226,6 +3219,11 @@ def setup_chat_routes(
|
||||
# pasted revision request. This only offers permitted schemas;
|
||||
# it never requires or performs a document mutation.
|
||||
_turn_capabilities = _turn_capabilities | {"documents"}
|
||||
# Scoped photo_editor family (see above): when active the turn offers
|
||||
# just the editor tools plus ask_user, never the general tool list.
|
||||
if _photo_editor_active:
|
||||
_turn_capabilities = frozenset({"photo_editor"})
|
||||
_active_turn_capabilities = _turn_capabilities
|
||||
# Same decision that prepared the turn; it only stamps the contract
|
||||
# inside the agent-contract branch below.
|
||||
_clean_v3_preview = _compact_preview_turn
|
||||
@@ -3238,12 +3236,6 @@ def setup_chat_routes(
|
||||
if _use_turn_contract and _turn_capabilities and "search_browser" not in _turn_capabilities:
|
||||
_explicit_web_intent = False
|
||||
disabled_tools = set()
|
||||
# Minting is admin-only, so every owner-keyed check below answers
|
||||
# "admin" for a token. Cap it at the non-admin policy instead.
|
||||
# stream_agent_loop repeats this from delegated_credential.
|
||||
_delegated_credential = is_delegated_credential(request)
|
||||
if _delegated_credential:
|
||||
disabled_tools.update(delegated_credential_blocked_tools())
|
||||
# Only disable bash when the caller *explicitly* set it to a falsy
|
||||
# value. When unset (None), defer to per-user privilege checks below.
|
||||
# Web search is per-turn opt-in: either the chat pre-search setting
|
||||
@@ -3475,9 +3467,22 @@ def setup_chat_routes(
|
||||
"document_editor": not {
|
||||
"manage_documents", "create_document", "edit_document", "update_document",
|
||||
}.issubset(disabled_tools),
|
||||
# A photo-editing chat turn can only reach a canvas when the image
|
||||
# editor is actually open. The compact runtime uses this to reply
|
||||
# (open the image in the editor) instead of looping on editor tools
|
||||
# that have no canvas to drive.
|
||||
"photo_editor_open": bool(editor_session),
|
||||
}
|
||||
# Capture permission state before schema selection/reconciliation.
|
||||
# Only deterministic request intent supplies grants, never inventory.
|
||||
# The strict router (spec) surface picks its tools in src/spec_router.py
|
||||
# AFTER authority is built, so its admission is keyed off the surface.
|
||||
try:
|
||||
_spec_surface = _configured_model_tool_surface(
|
||||
getattr(sess, "endpoint_url", ""), getattr(sess, "model", ""), _user
|
||||
) == "spec"
|
||||
except Exception:
|
||||
_spec_surface = False
|
||||
_request_authority = request_authority_for_http(
|
||||
request, message, owner=_user, session_id=session, workspace=workspace,
|
||||
history=_turn_history, policy=tool_policy,
|
||||
@@ -3485,9 +3490,16 @@ def setup_chat_routes(
|
||||
active_document=bool(active_doc),
|
||||
image_attachment=any(str(a.get('mime') or '').startswith('image/')
|
||||
for a in (ctx.preprocessed.attachment_meta or [])),
|
||||
capabilities=({'search_browser'} if (
|
||||
_explicit_browser_intent or _external_discovery_intent
|
||||
) else ()),
|
||||
spec_surface=_spec_surface,
|
||||
capabilities=(({"photo_editor"} if _photo_editor_active else set()) | ({'search_browser'} if (
|
||||
_explicit_browser_intent
|
||||
or _external_discovery_intent
|
||||
# Grant search/browser tools whenever the turn actually offers
|
||||
# them (Web toggle on / not denied), not only for explicit
|
||||
# browser wording. Otherwise the model sees web_search in its
|
||||
# schema but every call is rejected by request authority.
|
||||
or "web_search" not in disabled_tools
|
||||
) else set())),
|
||||
)
|
||||
if exact_tool_approval is not None:
|
||||
from src.agent_runtime.authority import RequestAuthority
|
||||
@@ -3513,8 +3525,6 @@ def setup_chat_routes(
|
||||
if s["function"]["name"] != "host_shell"]
|
||||
_contract_mgr = get_mcp_manager()
|
||||
_owner_blocked = blocked_tools_for_owner(_user)
|
||||
if _delegated_credential:
|
||||
_owner_blocked.update(delegated_credential_blocked_tools())
|
||||
if (
|
||||
_workspace_tools_disabled_for_owner(_user)
|
||||
and not _native_workspace_contract
|
||||
@@ -3677,7 +3687,9 @@ def setup_chat_routes(
|
||||
# personal manager; core shell/Web tools are not task
|
||||
# fallbacks. Other turns retain warm and workspace tools.
|
||||
extra_tools=(
|
||||
frozenset()
|
||||
frozenset({"ask_user"})
|
||||
if _active_turn_capabilities == frozenset({"photo_editor"})
|
||||
else frozenset()
|
||||
if _exact_selected_native_chain or _active_turn_capabilities == frozenset({"tasks"}) or (
|
||||
_active_turn_capabilities in (
|
||||
frozenset({"transcription"}), frozenset({"ocr"}),
|
||||
@@ -3711,10 +3723,17 @@ def setup_chat_routes(
|
||||
)
|
||||
# Every recovery path receives the same scope denial. The central
|
||||
# dispatcher also checks the immutable contract after rewrites.
|
||||
disabled_tools.update(
|
||||
s["function"]["name"] for s in _contract_schemas
|
||||
if not _turn_contract.permits(s["function"]["name"])
|
||||
)
|
||||
# The strict router (spec) surface picks its own tools AFTER this
|
||||
# contract is resolved, so scoping disabled_tools to the regex
|
||||
# contract would empty its menu (e.g. all email/notes tools denied
|
||||
# for a plain "search for the appointment email" turn). On spec
|
||||
# turns keep only the pre-contract denials (user/privilege/global/
|
||||
# plan/compare) and let src/spec_router.py + the spec surface decide.
|
||||
if not _spec_surface:
|
||||
disabled_tools.update(
|
||||
s["function"]["name"] for s in _contract_schemas
|
||||
if not _turn_contract.permits(s["function"]["name"])
|
||||
)
|
||||
# Contract resolution is the final policy-and-routing authority.
|
||||
# Some legacy/API-model paths arrive with a stale disabled snapshot
|
||||
# assembled before routing. The scope-denial pass above may retain
|
||||
@@ -3879,7 +3898,7 @@ def setup_chat_routes(
|
||||
_heartbeat_counter = 0
|
||||
_last_progress = {}
|
||||
_sent_avg = False
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
status = research_handler.get_status(session)
|
||||
if not status or status["status"] != "running":
|
||||
break
|
||||
@@ -4027,9 +4046,10 @@ def setup_chat_routes(
|
||||
owner=_user,
|
||||
size="auto",
|
||||
progress_callback=_image_progress_callback,
|
||||
incognito=incognito,
|
||||
))
|
||||
else:
|
||||
_img_task = asyncio.create_task(do_generate_image(f"{_user_msg}\n{sess.model}\n512x512", session, owner=_user))
|
||||
_img_task = asyncio.create_task(do_generate_image(f"{_user_msg}\n{sess.model}\n512x512", session, owner=_user, incognito=incognito))
|
||||
_img_started = time.time()
|
||||
_img_tick = 0
|
||||
while not _img_task.done():
|
||||
@@ -4568,7 +4588,6 @@ def setup_chat_routes(
|
||||
uploaded_files=ctx.uploaded_files,
|
||||
defer_context_shaping=_foreground_policy.enabled,
|
||||
external_untrusted_context_seen=external_untrusted_context_seen,
|
||||
delegated_credential=_delegated_credential,
|
||||
exact_approval=exact_tool_approval,
|
||||
client_runtime_context=client_runtime_context,
|
||||
thinking_mode=thinking_mode,
|
||||
@@ -4879,11 +4898,20 @@ def setup_chat_routes(
|
||||
async def _safe_stream() -> AsyncGenerator[str, None]:
|
||||
"""Wrapper that guarantees _active_streams cleanup even if stream_with_save
|
||||
raises before reaching a mode-specific finally block."""
|
||||
_gen = stream_with_save()
|
||||
try:
|
||||
async for chunk in stream_with_save():
|
||||
while not _shutdown.is_shutting_down():
|
||||
try:
|
||||
chunk = await _gen.__anext__()
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
yield chunk
|
||||
finally:
|
||||
_active_streams.pop(session, None)
|
||||
try:
|
||||
await _gen.aclose()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Compare panes and explicitly unattended native clients are
|
||||
# short-lived, single-shot generations with nobody to resume them.
|
||||
@@ -4975,7 +5003,12 @@ def setup_chat_routes(
|
||||
if rec is None:
|
||||
if agent_runs.is_active(session_id):
|
||||
return {"status": "streaming", "detached": True}
|
||||
raise HTTPException(404, "No active stream for this session")
|
||||
# No active stream: report an explicit idle state with HTTP 200.
|
||||
# A 404 here made every session load look like a failed request in
|
||||
# the browser console. The client distinguishes "no stream" by the
|
||||
# body (status != "streaming"), and still tolerates a 404 from an
|
||||
# older server, so this stays backward compatible.
|
||||
return {"status": "idle"}
|
||||
return rec
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
@@ -5074,6 +5107,8 @@ def setup_chat_routes(
|
||||
max_tokens=0,
|
||||
tools=None,
|
||||
):
|
||||
if _shutdown.is_shutting_down():
|
||||
break
|
||||
if chunk.startswith("data: ") and not chunk.startswith("data: [DONE]"):
|
||||
try:
|
||||
data = json.loads(chunk[6:])
|
||||
|
||||
@@ -701,17 +701,13 @@ def setup_cookbook_routes() -> APIRouter:
|
||||
return cmd
|
||||
|
||||
repo_id = "cyankiwi/MiniMax-M3-AWQ-INT4"
|
||||
hf_home = Path(os.environ.get("HF_HOME", str(Path.home() / ".cache" / "huggingface")))
|
||||
hf_cache = Path(os.environ.get("HUGGINGFACE_HUB_CACHE", str(hf_home / "hub")))
|
||||
snapshot_root = hf_cache / "models--cyankiwi--MiniMax-M3-AWQ-INT4" / "snapshots"
|
||||
if body[serve_i + 1] == repo_id and snapshot_root.is_dir():
|
||||
installed_snapshots = sorted(
|
||||
(p for p in snapshot_root.iterdir() if p.is_dir()),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if installed_snapshots:
|
||||
body[serve_i + 1] = str(installed_snapshots[0])
|
||||
snapshot = (
|
||||
"/home/pewds/.cache/huggingface/hub/"
|
||||
"models--cyankiwi--MiniMax-M3-AWQ-INT4/"
|
||||
"snapshots/4082acbbec1236d21828d55b6bb0fe02ade4ab5b"
|
||||
)
|
||||
if body[serve_i + 1] == repo_id:
|
||||
body[serve_i + 1] = snapshot
|
||||
|
||||
def add_env(key: str, value: str) -> None:
|
||||
if not any(p.startswith(f"{key}=") for p in env_parts):
|
||||
|
||||
@@ -7,14 +7,13 @@ import logging
|
||||
import re
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
|
||||
from core.models import ChatMessage
|
||||
from core.database import SessionLocal, ChatMessage as DbChatMessage, Session as DbSession
|
||||
from src.auth_helpers import effective_user, require_chat_api_token_scope
|
||||
from src.auth_helpers import effective_user
|
||||
from src.topic_analyzer import analyze_topics
|
||||
from src.upload_handler import reserve_message_upload_references
|
||||
from src.tool_approval_scopes import sanitize_client_message_metadata
|
||||
from routes.session_routes import (
|
||||
_message_role,
|
||||
_message_text,
|
||||
@@ -238,10 +237,7 @@ def _keep_count_before_message(db_messages, before_msg_id: str | None) -> int |
|
||||
|
||||
|
||||
def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
router = APIRouter(
|
||||
tags=["history"],
|
||||
dependencies=[Depends(require_chat_api_token_scope)],
|
||||
)
|
||||
router = APIRouter(tags=["history"])
|
||||
|
||||
def _reserve_message_uploads(
|
||||
request: Request,
|
||||
@@ -391,53 +387,26 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
async def truncate_session(request: Request, session_id: str):
|
||||
_verify_session_owner(request, session_id)
|
||||
try:
|
||||
try:
|
||||
body = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
raise HTTPException(400, "Request body must be valid JSON")
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(400, "Request body must be a JSON object")
|
||||
# Resolve before_msg_id / message_id alias with explicit presence checks.
|
||||
# None (JSON null) and empty string are both treated as "not provided here"
|
||||
# and fall through to the alias, preserving the original OR-chain semantics.
|
||||
# Any other non-string value (False, 0, True, 123, list, …) is rejected
|
||||
# immediately so falsy non-strings cannot bypass type validation.
|
||||
raw_msg_id = body.get("before_msg_id") if "before_msg_id" in body else None
|
||||
if raw_msg_id is None or raw_msg_id == "":
|
||||
raw_msg_id = body.get("message_id")
|
||||
if raw_msg_id is not None and not isinstance(raw_msg_id, str):
|
||||
raise HTTPException(400, "Message ID must be a string")
|
||||
before_msg_id = (raw_msg_id or "").strip()
|
||||
if "keep_count" not in body and not before_msg_id:
|
||||
raise HTTPException(400, "keep_count or before_msg_id required")
|
||||
raw_count = body.get("keep_count", 0)
|
||||
# Keep integer-string clients working, but do not silently convert
|
||||
# booleans or fractional numbers into destructive message counts.
|
||||
if type(raw_count) not in (int, str):
|
||||
raise HTTPException(400, "keep_count must be a non-negative integer")
|
||||
try:
|
||||
keep_count = int(raw_count)
|
||||
except ValueError:
|
||||
raise HTTPException(400, "keep_count must be a non-negative integer")
|
||||
if keep_count < 0:
|
||||
raise HTTPException(400, "keep_count must be a non-negative integer")
|
||||
body = await request.json()
|
||||
keep_count = int(body.get("keep_count", 0))
|
||||
before_msg_id = str(body.get("before_msg_id") or body.get("message_id") or "").strip()
|
||||
deleted_sft_pairs: list[dict[str, str]] = []
|
||||
# keep_count is always >= 0 here; the block is unconditional.
|
||||
db = SessionLocal()
|
||||
try:
|
||||
all_db_messages = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
if before_msg_id:
|
||||
resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id)
|
||||
if resolved_keep_count is None:
|
||||
raise HTTPException(404, "Message not found")
|
||||
keep_count = resolved_keep_count
|
||||
for pos, row in enumerate(all_db_messages):
|
||||
row._will_delete_for_sft = pos >= keep_count
|
||||
deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages)
|
||||
finally:
|
||||
db.close()
|
||||
if keep_count >= 0:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
all_db_messages = db.query(DbChatMessage).filter(
|
||||
DbChatMessage.session_id == session_id
|
||||
).order_by(DbChatMessage.timestamp).all()
|
||||
if before_msg_id:
|
||||
resolved_keep_count = _keep_count_before_message(all_db_messages, before_msg_id)
|
||||
if resolved_keep_count is None:
|
||||
raise HTTPException(404, "Message not found")
|
||||
keep_count = resolved_keep_count
|
||||
for pos, row in enumerate(all_db_messages):
|
||||
row._will_delete_for_sft = pos >= keep_count
|
||||
deleted_sft_pairs = _deleted_sft_pairs_from_db_rows(all_db_messages)
|
||||
finally:
|
||||
db.close()
|
||||
result = session_manager.truncate_messages(session_id, keep_count)
|
||||
_remove_deleted_sft_trace_rows(
|
||||
owner=effective_user(request),
|
||||
@@ -463,7 +432,7 @@ def setup_history_routes(session_manager, upload_handler=None) -> APIRouter:
|
||||
content = body.get("content", "")
|
||||
if not content:
|
||||
raise HTTPException(400, "content is required")
|
||||
metadata = sanitize_client_message_metadata(body.get("metadata"))
|
||||
metadata = body.get("metadata")
|
||||
_reserve_message_uploads(request, content, metadata)
|
||||
msg = ChatMessage(role=role, content=content, metadata=metadata)
|
||||
session_manager.add_message(session_id, msg)
|
||||
|
||||
@@ -17,6 +17,7 @@ from src.endpoint_resolver import resolve_endpoint
|
||||
from src.auth_helpers import _auth_disabled, get_current_user
|
||||
from src.owner_identity import REQUEST_SENTINEL_OWNERS
|
||||
from src.constants import DEEP_RESEARCH_DIR
|
||||
from src import shutdown as _shutdown
|
||||
|
||||
_SESSION_ID_RE = re.compile(r"^[a-zA-Z0-9-]{1,128}$")
|
||||
|
||||
@@ -285,11 +286,22 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
async def research_status(session_id: str, request: Request):
|
||||
user = _require_user(request)
|
||||
_validate_session_id(session_id)
|
||||
# No in-flight or persisted research for this session is a normal state
|
||||
# (most sessions have none), not an error. Return HTTP 200 with an
|
||||
# explicit empty status so loading a session does not log a spurious
|
||||
# 404; every client treats a non-"running" status as idle anyway.
|
||||
# A session whose research exists but belongs to ANOTHER user stays a
|
||||
# 404, so we do not leak the existence of someone else's research.
|
||||
if not _owns_in_memory(session_id, user):
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
exists = session_id in getattr(research_handler, "_active_tasks", {})
|
||||
if not exists:
|
||||
exists = _find_research_path(session_id) is not None
|
||||
if exists:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
return {"status": "none"}
|
||||
status = research_handler.get_status(session_id)
|
||||
if status is None:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
return {"status": "none"}
|
||||
try:
|
||||
source_state = research_handler.get_source_state(session_id)
|
||||
if isinstance(source_state, str) and source_state:
|
||||
@@ -657,7 +669,7 @@ def setup_research_routes(research_handler, session_manager=None) -> APIRouter:
|
||||
raise HTTPException(404, "No research found for this session")
|
||||
async def _generate():
|
||||
last_payload = None
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
status = research_handler.get_status(session_id)
|
||||
if status is None:
|
||||
yield f"data: {json.dumps({'status': 'not_found'})}\n\n"
|
||||
|
||||
@@ -43,22 +43,11 @@ def setup_search_routes(config) -> APIRouter:
|
||||
router = APIRouter(tags=["search"])
|
||||
|
||||
@router.get("/search/web", response_class=HTMLResponse)
|
||||
async def web_search_page(request: Request, q: str = Query("", min_length=0)) -> HTMLResponse:
|
||||
async def web_search_page(q: str = Query("", min_length=0)) -> HTMLResponse:
|
||||
"""Browser-facing search results page for clickable agent web_search rows."""
|
||||
# The site CSP allows inline script only with the per-request nonce
|
||||
# (SecurityHeadersMiddleware); without it this page's script never ran.
|
||||
nonce = html.escape(getattr(request.state, "csp_nonce", "") or "", quote=True)
|
||||
safe_q = str(q or "").strip()
|
||||
title = html.escape(safe_q or "Web search")
|
||||
# json.dumps leaves `<`, `>` and `&` alone, so q=</script><script ...>
|
||||
# would close this inline <script> and inject markup (reflected XSS).
|
||||
# Unicode-escape them; the JS string value is unchanged.
|
||||
q_json = (
|
||||
json.dumps(safe_q)
|
||||
.replace("<", "\\u003c")
|
||||
.replace(">", "\\u003e")
|
||||
.replace("&", "\\u0026")
|
||||
)
|
||||
q_json = json.dumps(safe_q)
|
||||
page = f"""<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -90,7 +79,7 @@ def setup_search_routes(config) -> APIRouter:
|
||||
<div class="status" id="status">Loading...</div>
|
||||
<div id="results"></div>
|
||||
</main>
|
||||
<script nonce="{nonce}">
|
||||
<script>
|
||||
const initialQuery = {q_json};
|
||||
const input = document.getElementById('query');
|
||||
const statusEl = document.getElementById('status');
|
||||
@@ -114,9 +103,7 @@ def setup_search_routes(config) -> APIRouter:
|
||||
}}
|
||||
statusEl.textContent = sources.length ? `${{sources.length}} results` : 'No results';
|
||||
resultsEl.innerHTML = sources.map(s => {{
|
||||
const rawUrl = String(s.url || s.link || '');
|
||||
// Result URLs come from third-party search results: only http(s) may become a link.
|
||||
const url = /^https?:[/][/]/i.test(rawUrl) ? rawUrl : '';
|
||||
const url = s.url || s.link || '';
|
||||
const title = s.title || url || 'Untitled';
|
||||
const snippet = s.snippet || s.content || '';
|
||||
return `<a class="result" href="${{esc(url)}}" target="_blank" rel="noopener noreferrer">
|
||||
|
||||
@@ -6,24 +6,17 @@ import uuid
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Form, HTTPException, Response, Request, Depends, Query
|
||||
from fastapi import APIRouter, Form, HTTPException, Response, Request, Query
|
||||
import logging
|
||||
|
||||
from core.session_manager import SessionManager
|
||||
from core.models import ChatMessage
|
||||
from src.request_models import SessionResponse
|
||||
from core.database import Session as DbSession, SessionLocal, Document, GalleryImage, utcnow_naive
|
||||
from src.auth_helpers import (
|
||||
effective_user,
|
||||
_auth_disabled,
|
||||
owner_filter,
|
||||
is_delegated_credential,
|
||||
require_chat_api_token_scope,
|
||||
)
|
||||
from src.auth_helpers import effective_user, _auth_disabled, owner_filter
|
||||
from src.session_image_cleanup import _generated_image_path_for_cleanup, session_image_refs
|
||||
from src.session_actions import is_session_recently_active
|
||||
from src.upload_handler import reserve_message_upload_references
|
||||
from src.tool_approval_scopes import sanitize_client_message_metadata
|
||||
|
||||
|
||||
def _sanitize_export_filename(name: str) -> str:
|
||||
@@ -241,15 +234,9 @@ def _verify_session_owner(request: Request, session_id: str, session_manager=Non
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api",
|
||||
tags=["sessions"],
|
||||
dependencies=[Depends(require_chat_api_token_scope)],
|
||||
)
|
||||
router = APIRouter(prefix="/api", tags=["sessions"])
|
||||
|
||||
def _current_user_is_admin(request: Request, user: str | None) -> bool:
|
||||
if is_delegated_credential(request):
|
||||
return False
|
||||
if not user:
|
||||
return False
|
||||
auth_mgr = getattr(request.app.state, "auth_manager", None)
|
||||
@@ -280,22 +267,6 @@ def _reject_raw_endpoint_url_for_non_admin(
|
||||
raise HTTPException(403, "Choose a registered model endpoint")
|
||||
|
||||
|
||||
def _reject_delegated_session_options(
|
||||
request: Request,
|
||||
*,
|
||||
skip_validation: bool = False,
|
||||
api_key: str | None = None,
|
||||
) -> None:
|
||||
"""Keep bearer credentials from exercising interactive-admin options."""
|
||||
if is_delegated_credential(request) and (
|
||||
skip_validation or bool((api_key or "").strip())
|
||||
):
|
||||
raise HTTPException(
|
||||
403,
|
||||
"API tokens cannot supply endpoint credentials or skip endpoint validation",
|
||||
)
|
||||
|
||||
|
||||
def _persist_session_headers(session_id: str, headers: dict | None) -> bool:
|
||||
"""Persist endpoint auth headers for DB-backed session metadata."""
|
||||
delays = (0.05, 0.15, 0.35)
|
||||
@@ -530,11 +501,6 @@ def setup_session_routes(
|
||||
):
|
||||
skip_val = str(skip_validation).lower() == "true"
|
||||
user = effective_user(request)
|
||||
_reject_delegated_session_options(
|
||||
request,
|
||||
skip_validation=skip_val,
|
||||
api_key=api_key,
|
||||
)
|
||||
endpoint_api_key = ""
|
||||
endpoint_base_url = ""
|
||||
_reject_raw_endpoint_url_for_non_admin(request, user, endpoint_id, endpoint_url)
|
||||
@@ -791,11 +757,7 @@ def setup_session_routes(
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
raise HTTPException(400, "Invalid message attachment metadata") from exc
|
||||
for m in messages:
|
||||
sess.add_message(ChatMessage(
|
||||
m["role"],
|
||||
m["content"],
|
||||
metadata=sanitize_client_message_metadata(m.get("metadata")),
|
||||
))
|
||||
sess.add_message(ChatMessage(m["role"], m["content"], metadata=m.get("metadata")))
|
||||
session_manager.save_sessions()
|
||||
return {"ok": True, "count": len(messages)}
|
||||
|
||||
@@ -1156,8 +1118,6 @@ def setup_session_routes(
|
||||
model: str = Form("gpt-4o"),
|
||||
rag: str = Form(None)
|
||||
):
|
||||
if is_delegated_credential(request):
|
||||
raise HTTPException(403, "This session type requires an interactive session")
|
||||
if not OPENAI_API_KEY:
|
||||
raise HTTPException(400, "Server missing OPENAI_API_KEY")
|
||||
sid = str(uuid.uuid4())
|
||||
@@ -1601,13 +1561,7 @@ def setup_session_routes(
|
||||
raise HTTPException(404, "Session not found")
|
||||
skills = _context_info_skill_inventory(skills_manager, owner=owner)
|
||||
tools = _context_info_tool_inventory()
|
||||
# `cwd` is caller-chosen. Walking it for AGENTS.md and reporting whether
|
||||
# it exists confirms paths on the host, which /api/workspace/browse and
|
||||
# /vet reserve for admins (or single-user mode); keep that boundary here.
|
||||
from src.tool_security import owner_is_admin_or_single_user
|
||||
|
||||
_host_paths_visible = owner_is_admin_or_single_user(owner)
|
||||
agents_md = _context_info_agents_md_inventory(cwd) if _host_paths_visible else []
|
||||
agents_md = _context_info_agents_md_inventory(cwd)
|
||||
# Workspace visibility: lets the TUI answer "can the backend actually
|
||||
# see this directory?" (mounted vs bridge-only) without probing.
|
||||
from src.workspace_paths import backend_workspace_path, workspace_mount_pairs
|
||||
@@ -1617,22 +1571,19 @@ def setup_session_routes(
|
||||
# Server-side tool policy: non-admin owners silently lose the computer
|
||||
# tools (src/tool_security); surface that so the TUI can show it.
|
||||
try:
|
||||
from src.tool_security import blocked_tools_for_owner, delegated_credential_blocked_tools
|
||||
from src.tool_security import blocked_tools_for_owner
|
||||
|
||||
_blocked = blocked_tools_for_owner(owner)
|
||||
if is_delegated_credential(request):
|
||||
_blocked.update(delegated_credential_blocked_tools())
|
||||
except Exception:
|
||||
_blocked = set()
|
||||
_computer = {"bash", "python", "read_file", "write_file", "host_shell"}
|
||||
_policy = {
|
||||
"computer_tools": "restricted" if _computer & _blocked else "full",
|
||||
"reason": "API-token caller" if is_delegated_credential(request) else
|
||||
"non-admin owner" if _blocked else "single-user or admin",
|
||||
"reason": "non-admin owner" if _blocked else "single-user or admin",
|
||||
}
|
||||
_workspace = {
|
||||
"backend_path": _backend_cwd,
|
||||
"exists_in_backend": _host_paths_visible and bool(_backend_cwd) and Path(_backend_cwd).is_dir(),
|
||||
"exists_in_backend": bool(_backend_cwd) and Path(_backend_cwd).is_dir(),
|
||||
"mount_configured": bool(workspace_mount_pairs()),
|
||||
"via_mount": bool(_raw_cwd) and backend_workspace_path(_raw_cwd) != _raw_cwd,
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ from src.host_docker_access import (
|
||||
)
|
||||
from src.optional_deps import prepare_optional_dependency_import
|
||||
from src.auth_helpers import _auth_disabled
|
||||
from src import shutdown as _shutdown
|
||||
from src import process_lifecycle
|
||||
|
||||
# POSIX-only: `pty`/`fcntl` transitively import `termios`, which does NOT exist
|
||||
@@ -1025,7 +1026,7 @@ async def _generate_tmux(cmd: str, request: Request):
|
||||
lines_sent = 0
|
||||
exit_code = None
|
||||
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
# Check client disconnect
|
||||
if await request.is_disconnected():
|
||||
# tmux keeps running — that's the whole point
|
||||
@@ -1142,7 +1143,7 @@ async def _generate_win_detached(cmd: str, request: Request):
|
||||
|
||||
lines_sent = 0
|
||||
exit_code = None
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
if await request.is_disconnected():
|
||||
yield f"data: {json.dumps({'stream': 'stdout', 'data': f'Disconnected. Background job {session_id} continues running.'})}\n\n"
|
||||
return
|
||||
@@ -1267,7 +1268,7 @@ def setup_shell_routes() -> APIRouter:
|
||||
"""Read chunks, split on \\n or \\r for progress bar support."""
|
||||
try:
|
||||
buf = b""
|
||||
while True:
|
||||
while not _shutdown.is_shutting_down():
|
||||
chunk = await stream.read(4096)
|
||||
if not chunk:
|
||||
if buf:
|
||||
|
||||
+15
-22
@@ -19,7 +19,7 @@ from pydantic import BaseModel, Field
|
||||
from services.memory.skills import SkillsManager
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.prompt_security import untrusted_context_message
|
||||
from src.text_helpers import strip_closed_think_blocks
|
||||
from src.model_profiles import uses_odysseus_progressive_thinking
|
||||
from core.middleware import require_admin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -251,7 +251,7 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
||||
# Strip closed think blocks. If a <think> was opened but never closed
|
||||
# (the model ran out of budget mid-reasoning), drop everything from it
|
||||
# onward so its stray braces don't poison JSON extraction.
|
||||
text = strip_closed_think_blocks(text)
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', text, flags=_re.I)
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
|
||||
|
||||
def _coerce(d):
|
||||
@@ -348,6 +348,8 @@ async def _eval_skill_run(skill_md: str, task: str, transcript: str,
|
||||
url, model, msgs,
|
||||
temperature=0.1, max_tokens=32768, headers=headers, timeout=180,
|
||||
workload=workload,
|
||||
thinking_mode=('on' if attempt == 0 else 'off')
|
||||
if uses_odysseus_progressive_thinking(model) else None,
|
||||
)
|
||||
except Exception as e:
|
||||
# Don't give up on a transient first-attempt error — let the second
|
||||
@@ -400,11 +402,12 @@ async def _eval_skill_necessity(skill_md: str, others: list, url: str, model: st
|
||||
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}],
|
||||
temperature=0.1, max_tokens=8192, headers=headers, timeout=120,
|
||||
workload=workload,
|
||||
thinking_mode='on' if uses_odysseus_progressive_thinking(model) else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Necessity check failed: {e}")
|
||||
return None
|
||||
text = strip_closed_think_blocks(raw or '')
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
|
||||
data = None
|
||||
a, b = text.find('{'), text.rfind('}')
|
||||
@@ -490,11 +493,12 @@ async def _eval_skill_retrieval_precision(skill_md: str, others: list,
|
||||
[{"role": "system", "content": sys_prompt}, {"role": "user", "content": user_msg}],
|
||||
temperature=0.1, max_tokens=4096, headers=headers, timeout=90,
|
||||
workload=workload,
|
||||
thinking_mode='on' if uses_odysseus_progressive_thinking(model) else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Retrieval precision check failed: {e}")
|
||||
return None
|
||||
text = strip_closed_think_blocks(raw or '')
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I).strip()
|
||||
data = None
|
||||
a, b = text.find('{'), text.rfind('}')
|
||||
@@ -1185,11 +1189,12 @@ async def _improve_skill_md(skill_md: str, verdict: dict, transcript: str, url,
|
||||
[{"role": "system", "content": sys_prompt},
|
||||
{"role": "user", "content": user_msg}],
|
||||
temperature=0.2, max_tokens=16384, headers=headers, timeout=180,
|
||||
workload=workload)
|
||||
workload=workload,
|
||||
thinking_mode='on' if uses_odysseus_progressive_thinking(model) else None)
|
||||
except Exception as e:
|
||||
logger.warning(f"Audit: improve call failed: {e}")
|
||||
return None
|
||||
text = strip_closed_think_blocks(raw or '')
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', (raw or ''), flags=_re.I)
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*$', '', text, flags=_re.I)
|
||||
text = _re.sub(r'</think(?:ing)?>', '', text, flags=_re.I).strip()
|
||||
if text.startswith("```"):
|
||||
@@ -2004,11 +2009,7 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||
it untouched). It never changes the skill's published/draft STATUS."""
|
||||
import time as _time
|
||||
import asyncio as _asyncio
|
||||
from core.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url, build_headers, resolve_endpoint,
|
||||
resolve_endpoint_runtime, resolve_owner_registered_endpoint,
|
||||
)
|
||||
from src.endpoint_resolver import resolve_endpoint
|
||||
|
||||
user = _owner(request)
|
||||
body = await request.json()
|
||||
@@ -2032,18 +2033,10 @@ def setup_skills_routes(skills_manager: SkillsManager) -> APIRouter:
|
||||
# session's model. Fall back to the caller's session model only if unset.
|
||||
url, model, headers = resolve_endpoint("utility", owner=user)
|
||||
if not url or not model:
|
||||
if not url and body.get("endpoint_url") is not None:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
endpoint = resolve_owner_registered_endpoint(db, body["endpoint_url"], user)
|
||||
base, api_key = resolve_endpoint_runtime(endpoint, owner=user)
|
||||
url = build_chat_url(base)
|
||||
headers = build_headers(api_key, base)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
finally:
|
||||
db.close()
|
||||
url = url or ((body.get("endpoint_url") or "").strip() or None)
|
||||
model = model or ((body.get("model") or "").strip() or None)
|
||||
if headers is None and isinstance(body.get("headers"), dict):
|
||||
headers = body.get("headers")
|
||||
if not url or not model:
|
||||
raise HTTPException(400, "No model configured — set a Default or Utility model in Settings.")
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ from core.database import SessionLocal, ScheduledTask, TaskRun, NotificationLog
|
||||
from core.constants import internal_api_base
|
||||
from src.auth_helpers import get_current_user
|
||||
from src.constants import DATA_DIR, EMAIL_URGENCY_CACHE_DIR
|
||||
from src.endpoint_resolver import resolve_owner_registered_endpoint_url
|
||||
from src.task_action_policy import (
|
||||
ADMIN_ONLY_TASK_ACTIONS,
|
||||
is_admin_only_task_action,
|
||||
@@ -520,12 +519,6 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
then_task_id = _validate_then_task_id(db, req.then_task_id, user)
|
||||
endpoint_url = None
|
||||
if req.endpoint_url:
|
||||
try:
|
||||
endpoint_url = resolve_owner_registered_endpoint_url(db, req.endpoint_url, user)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
notifications_enabled = (
|
||||
False if req.task_type == "action" and req.notifications_enabled is None
|
||||
else bool(req.notifications_enabled) if req.notifications_enabled is not None
|
||||
@@ -562,7 +555,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
status="active" if (req.trigger_type in ("event", "webhook") or next_run) else "completed",
|
||||
output_target=req.output_target,
|
||||
model=req.model or None,
|
||||
endpoint_url=endpoint_url,
|
||||
endpoint_url=req.endpoint_url or None,
|
||||
then_task_id=then_task_id,
|
||||
webhook_token=webhook_token,
|
||||
notifications_enabled=notifications_enabled,
|
||||
@@ -618,8 +611,12 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
async def create_notification_log(request: Request):
|
||||
"""Persist an in-app toast so Settings can show notification history."""
|
||||
user = _owner(request)
|
||||
# Toast logging is best-effort. With auth disabled (or no user) there is
|
||||
# nothing meaningful to persist, so return success rather than a 401 —
|
||||
# a 401 here would trip the global fetch redirect-to-/login handler and
|
||||
# yank users out of the editor mid-edit.
|
||||
if not user:
|
||||
raise HTTPException(401, "Authentication required")
|
||||
return {"success": False}
|
||||
body = await request.json()
|
||||
message = str(body.get("body") or "").strip()[:2000]
|
||||
if not message:
|
||||
@@ -762,14 +759,7 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
if req.model is not None:
|
||||
task.model = req.model or None
|
||||
if req.endpoint_url is not None:
|
||||
try:
|
||||
# An empty override restores the existing default-model workflow.
|
||||
task.endpoint_url = (
|
||||
resolve_owner_registered_endpoint_url(db, req.endpoint_url, user)
|
||||
if req.endpoint_url else None
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
task.endpoint_url = req.endpoint_url or None
|
||||
if req.trigger_type is not None:
|
||||
# Generate webhook token when switching to webhook trigger
|
||||
if req.trigger_type == "webhook" and not task.webhook_token:
|
||||
@@ -1252,14 +1242,8 @@ def setup_task_routes(task_scheduler) -> APIRouter:
|
||||
if not out.get("prompt"):
|
||||
return {"success": False, "message": "Could not extract a task instruction"}
|
||||
return {"success": True, "draft": out}
|
||||
except HTTPException as e:
|
||||
# llm_core raises HTTPException with curated upstream text.
|
||||
logger.error(f"parse_task failed: {e.detail}")
|
||||
return {"success": False, "message": str(e.detail)}
|
||||
except Exception as e:
|
||||
# Anything else (JSON/parse/runtime errors) can carry internals;
|
||||
# log it and keep the client message generic.
|
||||
logger.error(f"parse_task failed: {e}")
|
||||
return {"success": False, "message": f"Could not draft a task ({type(e).__name__})"}
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
return router
|
||||
|
||||
@@ -610,9 +610,7 @@ async def update_persona_memory(
|
||||
)
|
||||
|
||||
updated = strip_think(str(raw or ""), prose=True, prompt_echo=True).strip()
|
||||
# No leading `\s*` before the closing fence: .strip() drops that
|
||||
# whitespace anyway, and scanning it from every offset was quadratic.
|
||||
updated = re.sub(r"^```(?:text|markdown)?\s*|```$", "", updated, flags=re.I | re.S).strip()
|
||||
updated = re.sub(r"^```(?:text|markdown)?\s*|\s*```$", "", updated, flags=re.I | re.S).strip()
|
||||
if len(updated) > 6000:
|
||||
updated = updated[:6000].rstrip()
|
||||
if updated == existing_memory:
|
||||
@@ -696,9 +694,8 @@ async def audit_memories(
|
||||
# Parse the JSON list, tolerating reasoning-model noise: <think> blocks,
|
||||
# markdown fences, leading prose, and trailing commas.
|
||||
import re as _re
|
||||
from src.text_helpers import strip_closed_think_blocks
|
||||
text = (raw or "").strip()
|
||||
text = strip_closed_think_blocks(text).strip()
|
||||
text = _re.sub(r'<think(?:ing)?>[\s\S]*?</think(?:ing)?>', '', text, flags=_re.I).strip()
|
||||
|
||||
def _loads_list(s):
|
||||
if not s:
|
||||
@@ -714,9 +711,7 @@ async def audit_memories(
|
||||
|
||||
cleaned = _loads_list(text)
|
||||
if cleaned is None:
|
||||
# Possessive `\s*+`: handing fence whitespace back to the body on a
|
||||
# missing closing fence only rescanned the same tail (ReDoS).
|
||||
_m = _re.search(r'```(?:json)?\s*+\n?([\s\S]*?)```', text)
|
||||
_m = _re.search(r'```(?:json)?\s*\n?([\s\S]*?)```', text)
|
||||
if _m:
|
||||
cleaned = _loads_list(_m.group(1).strip())
|
||||
if cleaned is None:
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ This spec covers the current browser app in:
|
||||
|
||||
`/backgrounds` currently targets `static/backgrounds.html`; if that route remains, the file must exist or the route should be removed.
|
||||
|
||||
The static PWA icon files (192px, 512px, maskable) were deliberately omitted from publication, and `static/icons/` is intentionally absent. `static/manifest.json` declares no `icons`, and neither `static/index.html` nor `static/login.html` ships a static `apple-touch-icon` link. Icons come from generated inline SVG at runtime: the `data:image/svg+xml` favicon, the `apple-touch-icon` link that `static/index.html` and `static/js/theme.js` create, and per-route Blob manifests whose `icons` point at that SVG. Do not reintroduce references to icon files that are not shipped.
|
||||
`static/manifest.json`, `static/index.html`, and `static/login.html` reference first-party PWA icon files under `static/icons/` (192px, 512px, and maskable). At runtime, route-specific manifests can also be generated as Blob URLs when supported, and per-route favicon links use generated inline SVG.
|
||||
|
||||
## Current Call Sites Include
|
||||
|
||||
@@ -81,7 +81,7 @@ Storage/secrets policy:
|
||||
- other static assets use cache-first with background refresh;
|
||||
- `CACHE_NAME` bumps and `PRECACHE` updates must accompany cache policy or shell asset changes.
|
||||
|
||||
`static/manifest.json` owns default PWA metadata and intentionally has no `icons` entry. Route-specific manifests can be generated as Blob URLs when supported; their icons are the route's generated inline SVG, not files.
|
||||
`static/manifest.json` owns default PWA metadata, referencing the restored icons under `static/icons/`. Route-specific manifests can be generated as Blob URLs when supported; dynamic icons use the route's generated inline SVG, while the default manifest uses the static icons.
|
||||
|
||||
KaTeX and Mermaid are self-hosted and lazy-loaded through memoized, retry-after-failure promises in `static/js/markdown.js`; math placeholders preserve source until KaTeX arrives, detached PDF export renders its own container, and Mermaid fetches only when a diagram exists. Pyodide remains a jsDelivr-loaded optional runtime, so offline/PWA behavior is not fully self-contained.
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ def workspace_artifact_is_usable(path: Path) -> bool:
|
||||
class EvidenceKind(str, Enum):
|
||||
TOOL_RESULT = "tool_result"
|
||||
ARTIFACT_MUTATION = "artifact_mutation"
|
||||
DOCUMENT_MUTATION = "document_mutation"
|
||||
ARTIFACT_VALIDATION = "artifact_validation"
|
||||
VERIFIER_RESULT = "verifier_result"
|
||||
MEDIA_INGRESS = "media_ingress"
|
||||
@@ -131,6 +132,20 @@ class CompletionDecision:
|
||||
|
||||
|
||||
_ARTIFACT_PATH = r"(?:/|\./|\.\./)?[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*\.[A-Za-z0-9]{1,12}"
|
||||
# Document-editor tools mutate a server-side document rather than a filesystem
|
||||
# path, so their successful results are recorded as a distinct evidence kind.
|
||||
_DOCUMENT_MUTATION_TOOLS = frozenset({"create_document", "update_document", "edit_document"})
|
||||
# Owned private-action writes (calendar, tasks, notes, memory, contacts) mutate
|
||||
# server-side state that carries no filesystem path, so a successful
|
||||
# authoritative result is their mutation evidence. Without this, a verified
|
||||
# calendar/task create is discarded as an "unsupported execution claim".
|
||||
_PRIVATE_WRITE_ACTIONS: Mapping[str, frozenset[str]] = {
|
||||
"manage_calendar": frozenset({"create_event", "update_event", "delete_event"}),
|
||||
"manage_contact": frozenset({"add", "update", "edit", "delete"}),
|
||||
"manage_memory": frozenset({"add", "edit", "delete"}),
|
||||
"manage_notes": frozenset({"add", "update", "delete", "toggle_item"}),
|
||||
"manage_tasks": frozenset({"create", "edit", "delete", "pause", "resume", "run"}),
|
||||
}
|
||||
_ARTIFACT_REQUEST_RE = re.compile(
|
||||
rf"\b(?:writ(?:e|ten)|creat(?:e|ed)|make|made|sav(?:e|ed)|produc(?:e|ed)|"
|
||||
rf"generat(?:e|ed)|export(?:ed)?|edit(?:ed)?|modif(?:y|ied)|updat(?:e|ed)|"
|
||||
@@ -639,6 +654,9 @@ class EvidenceLedger:
|
||||
# order of actions so receipt evidence and effects share one ordering.
|
||||
self.effects: list[dict[str, Any]] = []
|
||||
self._action_order: dict[str, int] = {}
|
||||
# Successful authoritative owned-state writes (calendar/tasks/notes/
|
||||
# memory/contacts) that an unnamed mutation claim may cite.
|
||||
self._private_action_mutations: list[str] = []
|
||||
|
||||
def record_effects(self, entries: Iterable[Mapping[str, Any]], action_order: Mapping[str, int],
|
||||
partial_reads: Iterable[str] = ()) -> None:
|
||||
@@ -898,6 +916,29 @@ class EvidenceLedger:
|
||||
detail="post-write artifact inspection",
|
||||
)
|
||||
|
||||
if tool in _DOCUMENT_MUTATION_TOOLS:
|
||||
# A successful, authoritative document write is evidence of a
|
||||
# document mutation even though it has no filesystem path.
|
||||
self._append(
|
||||
kind=EvidenceKind.DOCUMENT_MUTATION,
|
||||
success=success,
|
||||
authoritative=authoritative,
|
||||
source=event,
|
||||
detail=tool,
|
||||
)
|
||||
|
||||
if tool in _PRIVATE_WRITE_ACTIONS:
|
||||
try:
|
||||
write_args = json.loads(command or "{}")
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
write_args = None
|
||||
action = (
|
||||
str(write_args.get("action") or "").strip().lower()
|
||||
if isinstance(write_args, Mapping) else ""
|
||||
)
|
||||
if action in _PRIVATE_WRITE_ACTIONS[tool] and authoritative and success:
|
||||
self._private_action_mutations.append(f"{tool}:{action}")
|
||||
|
||||
if tool in {"bash", "host_shell"} and (command_is_test(_command_text(command)) or _matches_declared_verifier(
|
||||
command,
|
||||
self.requirements.verifier_commands,
|
||||
@@ -969,6 +1010,22 @@ class EvidenceLedger:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _supports_document_mutation(self) -> bool:
|
||||
"""A successful authoritative document write supports an unnamed
|
||||
document-mutation claim, which carries no filesystem path to match."""
|
||||
return any(event.kind == EvidenceKind.DOCUMENT_MUTATION
|
||||
and event.authoritative and event.success
|
||||
for event in self.events)
|
||||
|
||||
def _supports_mutation_claim(self, paths: Sequence[str]) -> bool:
|
||||
"""Match a mutation claim to a file edit, a document write, or a
|
||||
successful owned-state action when the claim names no file path."""
|
||||
if self._supports_artifact_claim(EvidenceKind.ARTIFACT_MUTATION, paths):
|
||||
return True
|
||||
if paths:
|
||||
return False
|
||||
return self._supports_document_mutation() or bool(self._private_action_mutations)
|
||||
|
||||
def record_media_ingress(self, metadata: Mapping[str, Any]) -> None:
|
||||
for artifact in metadata.get("artifacts") or []:
|
||||
if not isinstance(artifact, Mapping):
|
||||
|
||||
+282
-909
File diff suppressed because it is too large
Load Diff
@@ -316,7 +316,12 @@ class RequestAuthority:
|
||||
|
||||
|
||||
_BROWSER_READ_ACTIONS = frozenset({"open", "navigate", "snapshot", "text", "read", "find",
|
||||
"screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info"})
|
||||
"screenshot", "scroll", "back", "forward", "wait", "status", "close", "tabs", "session_info",
|
||||
"batch"})
|
||||
# Interaction classes are admitted on a strict router (spec) surface whenever
|
||||
# the server itself offered private_browser: the router decides the tool set,
|
||||
# so requiring the verb in the text would break "browse X then click Y".
|
||||
_BROWSER_INTERACTION_ACTIONS = frozenset({"click", "fill", "type", "press", "evaluate", "select"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -363,7 +368,8 @@ def interpret_request(request_text, *, history=(), workspace=None, active_docume
|
||||
|
||||
def create_request_authority(request_text, *, owner=None, session_id=None, workspace=None,
|
||||
history=(), policy=None, active_document=False,
|
||||
image_attachment=False, capabilities=None, client_runtime_context=None):
|
||||
image_attachment=False, capabilities=None, client_runtime_context=None,
|
||||
spec_surface=False):
|
||||
"""Deterministic server policy over semantic facts, never schema inventory."""
|
||||
if not isinstance(request_text, str):
|
||||
raise TypeError("Authority requires trusted request text")
|
||||
@@ -372,10 +378,25 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
|
||||
families = intent.capabilities
|
||||
if capabilities is not None:
|
||||
families |= frozenset(capabilities)
|
||||
if spec_surface:
|
||||
# Strict router (spec) surface: agent_loop builds the final tool set in
|
||||
# src/spec_router.py AFTER this runs, so turn_contract.offered is still
|
||||
# the compact inventory here. The router already limits schemas to its
|
||||
# picks + sticky prior tools + ask_user/update_plan, and
|
||||
# _resolve_tool_blocks drops calls to unoffered tools, so admit every
|
||||
# family and let the spec surface be the boundary. Selected-tool
|
||||
# narrowing is skipped for the same reason.
|
||||
families |= frozenset(FAMILY_TOOLS)
|
||||
tools = set().union(*(FAMILY_TOOLS.get(f, ()) for f in families))
|
||||
selected = intent.selected_tools
|
||||
selected = None if spec_surface else intent.selected_tools
|
||||
if selected is not None:
|
||||
tools = tools & set(selected) if families else set(selected)
|
||||
if "web_search" in tools:
|
||||
# Search selection narrows to the query tool, but reading a source
|
||||
# it surfaces is the same bounded read operation. Without the
|
||||
# sibling reader every discovered result is a dead end and the
|
||||
# turn fails closed on the first web_fetch of a found URL.
|
||||
tools.add("web_fetch")
|
||||
if tools & {"web_search", "web_fetch"}:
|
||||
tools.add("private_browser")
|
||||
operation = intent.required_read
|
||||
@@ -389,13 +410,28 @@ def create_request_authority(request_text, *, owner=None, session_id=None, works
|
||||
actions = inputs = None
|
||||
if name == "private_browser":
|
||||
actions = _BROWSER_READ_ACTIONS
|
||||
# Explicit interaction intent admits its operation class. A
|
||||
# browser offered only as static-Web fallback gets no such grant.
|
||||
if re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I):
|
||||
if spec_surface:
|
||||
# The strict router itself offered a working browser: admit its
|
||||
# interaction classes too, not only when the verb is in the text.
|
||||
actions |= _BROWSER_INTERACTION_ACTIONS
|
||||
elif re.search(r"\b(?:browser|browse|private_browser)\b", request_text, re.I):
|
||||
# Explicit interaction intent admits its operation class. A
|
||||
# browser offered only as static-Web fallback gets no such grant.
|
||||
actions |= frozenset(action for action in ("click", "fill", "type", "press", "evaluate", "select")
|
||||
if re.search(r"\b" + action + r"\b", request_text, re.I))
|
||||
if operation is not None and name == canonical_tool(operation.tool):
|
||||
inputs = frozenset({ExactOperation.normalize(name, _json(dict(operation.args))).input})
|
||||
required_args = dict(operation.args)
|
||||
if set(required_args) <= {"action"}:
|
||||
# An action-only read ("list my tasks/memories") pins the action
|
||||
# class, not the exact JSON: a model that adds benign arguments
|
||||
# (limit/offset/filter) still performs the same admitted read.
|
||||
# Reads that name an identity keep the exact-input seal below.
|
||||
required_action = str(required_args.get("action") or "").strip().casefold() or None
|
||||
if required_action:
|
||||
actions = (frozenset({required_action}) if actions is None
|
||||
else actions & frozenset({required_action}))
|
||||
else:
|
||||
inputs = frozenset({ExactOperation.normalize(name, _json(required_args)).input})
|
||||
grants.append(OperationGrant(name, actions, inputs))
|
||||
authority = RequestAuthority(uuid4().hex, _owner(owner), str(session_id or ""),
|
||||
str(workspace or ""), tuple(grants))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""One presentation gate between agent execution and externally visible prose.
|
||||
|
||||
Tool, progress and interaction events stay live. Answer deltas are held until
|
||||
the generator unwinds so a later replacement cannot conceal an earlier false
|
||||
Tool, progress and interaction events stay live. Evidence-safe complete
|
||||
sentences from ordinary answers may stream before the terminal check; execution
|
||||
answers remain gated so a later replacement cannot conceal an earlier false
|
||||
claim. This consumes no provider calls. Cancellation closes the inner generator
|
||||
under the same journal/turn authority; it never emits a successful terminal event.
|
||||
"""
|
||||
@@ -123,7 +124,7 @@ def _supported_prose(text: str, ledger: EvidenceLedger, decision: CompletionDeci
|
||||
or not ledger._supports_verifier_claim(identities, paths)):
|
||||
why = 'no current passing executable verification supports the claim'
|
||||
elif claim == 'mutation':
|
||||
if not ledger._supports_artifact_claim(EvidenceKind.ARTIFACT_MUTATION, paths):
|
||||
if not ledger._supports_mutation_claim(paths):
|
||||
why = 'no matching artifact mutation supports the execution claim'
|
||||
elif claim == 'execution':
|
||||
# A generic assertion cannot be tied confidently to a receipt.
|
||||
@@ -167,6 +168,16 @@ def _disclosure(answer: str, ledger: EvidenceLedger) -> str:
|
||||
return ('\n\n' + summary) if answer.strip() else summary
|
||||
|
||||
|
||||
def _complete_prose_prefix(text: str) -> str:
|
||||
"""Return complete statement segments, keeping the current sentence pending."""
|
||||
statements = list(_unquoted_statements(text))
|
||||
if not statements:
|
||||
return ''
|
||||
if statements[-1][0].strip():
|
||||
statements.pop()
|
||||
return ''.join(statement for statement, _ in statements)
|
||||
|
||||
|
||||
def _completion_answer(text: str, ledger: EvidenceLedger, decision: CompletionDecision) -> tuple[str, str]:
|
||||
incomplete = decision.reason if not decision.can_complete and decision.status != CompletionStatus.AWAITING_USER else ''
|
||||
execution_required = _execution_obligation(ledger.requirements)
|
||||
@@ -232,6 +243,10 @@ def with_completion_gate(func):
|
||||
journal.effects = (parent.effects if parent is not None and parent.effects is not None
|
||||
else EffectLog(journal.run_id))
|
||||
answer_events: list[dict] = []
|
||||
streamed_answer_events: set[int] = set()
|
||||
streamed_safe_text = ''
|
||||
stream_conflict = False
|
||||
first_visible_answer_at = None
|
||||
metrics_events: list[dict] = []
|
||||
answer = ''
|
||||
has_final = False
|
||||
@@ -297,18 +312,36 @@ def with_completion_gate(func):
|
||||
first_answer_at = perf_counter()
|
||||
# Boolean thinking=True marks a reasoning-only delta;
|
||||
# a textual thinking companion must not hide an answer
|
||||
# delta. Both shapes remain buffered until the gate.
|
||||
# delta. Thinking remains buffered until terminal review.
|
||||
if isinstance(data.get('thinking'), str):
|
||||
answer_events.append({'delta': data['thinking'], 'thinking': True})
|
||||
thinking_event = {'delta': data['thinking'], 'thinking': True}
|
||||
answer_events.append(thinking_event)
|
||||
data = {key: value for key, value in data.items() if key != 'thinking'}
|
||||
if 'delta' not in data:
|
||||
continue
|
||||
if data.get('thinking') is not True and 'delta' in data:
|
||||
if has_final:
|
||||
stream_conflict |= bool(streamed_safe_text)
|
||||
streamed_safe_text = ''
|
||||
answer = ''
|
||||
has_final = False
|
||||
answer += str(data.get('delta') or '')
|
||||
answer_events.append(data)
|
||||
if data.get('thinking') is not True and 'delta' in data \
|
||||
and not _execution_obligation(requirements):
|
||||
prefix = _complete_prose_prefix(answer)
|
||||
if prefix:
|
||||
ledger_now = _ledger(journal, requirements)
|
||||
decision_now = ledger_now.evaluate(exhausted=exhausted, awaiting_user=awaiting)
|
||||
safe_prefix, _ = _supported_prose(prefix, ledger_now, decision_now)
|
||||
if safe_prefix.startswith(streamed_safe_text):
|
||||
delta = safe_prefix[len(streamed_safe_text):]
|
||||
if delta:
|
||||
first_visible_answer_at = first_visible_answer_at or perf_counter()
|
||||
yield _event({'delta': delta})
|
||||
streamed_safe_text = safe_prefix
|
||||
elif streamed_safe_text:
|
||||
stream_conflict = True
|
||||
continue
|
||||
yield chunk
|
||||
if provider_error and not answer_events and not metrics_events:
|
||||
@@ -361,7 +394,10 @@ def with_completion_gate(func):
|
||||
disclosure_only = bool(disclosure) and not (presentation_replaced or reason or unsafe_draft
|
||||
or filtered_answer != answer)
|
||||
replaced_answer = not disclosure_only and bool(
|
||||
presentation_replaced or reason or unsafe_draft or safe_answer != answer)
|
||||
presentation_replaced or reason or unsafe_draft or safe_answer != answer or stream_conflict
|
||||
or (provider_error and streamed_safe_text))
|
||||
if streamed_safe_text and not answer.startswith(streamed_safe_text):
|
||||
replaced_answer = True
|
||||
if replaced_answer:
|
||||
reasoning = [event for event in answer_events if event.get('thinking') is True]
|
||||
_, unsafe_reasoning = completion_answer(
|
||||
@@ -369,11 +405,31 @@ def with_completion_gate(func):
|
||||
replace(presentation_decision, can_complete=True))
|
||||
if not unsafe_reasoning:
|
||||
for event in reasoning:
|
||||
yield _event(event)
|
||||
yield _event({'type': 'final_response', 'content': safe_answer})
|
||||
if id(event) not in streamed_answer_events:
|
||||
yield _event(event)
|
||||
yield _event({
|
||||
'type': 'final_response',
|
||||
'content': safe_answer,
|
||||
'render_owner': 'streamed',
|
||||
'replacement_scope': 'turn',
|
||||
})
|
||||
else:
|
||||
for event in answer_events:
|
||||
yield _event(event)
|
||||
if event.get('thinking') is True:
|
||||
if id(event) not in streamed_answer_events:
|
||||
streamed_answer_events.add(id(event))
|
||||
first_visible_answer_at = first_visible_answer_at or perf_counter()
|
||||
yield _event(event)
|
||||
elif event.get('type') == 'final_response':
|
||||
if streamed_safe_text:
|
||||
event = {**event, 'replacement_scope': 'turn'}
|
||||
first_visible_answer_at = first_visible_answer_at or perf_counter()
|
||||
yield _event(event)
|
||||
if not has_final:
|
||||
remainder = answer[len(streamed_safe_text):] if answer.startswith(streamed_safe_text) else answer
|
||||
if remainder:
|
||||
first_visible_answer_at = first_visible_answer_at or perf_counter()
|
||||
yield _event({'delta': remainder})
|
||||
if disclosure_only:
|
||||
yield _event({'delta': disclosure})
|
||||
if provider_error:
|
||||
@@ -386,8 +442,11 @@ def with_completion_gate(func):
|
||||
if ledger.effects:
|
||||
metadata['effect_assessments'] = [entry['assessment'].to_dict() for entry in ledger.effects]
|
||||
metadata['completion_gate'] = {
|
||||
'buffer_seconds': released_at - first_answer_at if first_answer_at is not None else 0,
|
||||
'first_visible_answer_seconds': released_at - started,
|
||||
'buffer_seconds': max(0, (first_visible_answer_at or released_at) - first_answer_at)
|
||||
if first_answer_at is not None else 0,
|
||||
'evaluation_seconds': released_at - first_answer_at if first_answer_at is not None else 0,
|
||||
'first_visible_answer_seconds': first_visible_answer_at - started
|
||||
if first_visible_answer_at is not None else 0,
|
||||
'additional_provider_calls': 0,
|
||||
'answer_replaced': replaced_answer,
|
||||
}
|
||||
|
||||
@@ -114,9 +114,11 @@ def _control_plane_snapshot():
|
||||
uploader = get_upload_handler()
|
||||
if uploader is not None and isinstance(getattr(uploader, "upload_dir", None), (str, os.PathLike)):
|
||||
protected.add(canonical_root(Path(uploader.upload_dir) / "uploads.json"))
|
||||
# Prefix protection covers the complete runtime trees. Public policy denies
|
||||
# every regular hardlink alias, so collecting all child inodes here would
|
||||
# add an unbounded recursive state inventory without widening protection.
|
||||
for directory in job_dirs:
|
||||
jobs = Path(directory)
|
||||
if jobs.exists():
|
||||
# Uninspectable state fails closed; hardlinks retain object identity.
|
||||
protected.update(canonical_root(p) for p in jobs.rglob("*") if p.is_file())
|
||||
protected.update(canonical_root(getattr(constants, name) + suffix)
|
||||
for name in ("APP_DB", "SCHEDULED_EMAILS_DB", "EMAIL_CACHE_DB")
|
||||
for suffix in ("-wal", "-shm", "-journal"))
|
||||
@@ -129,7 +131,7 @@ def _control_plane_snapshot():
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
identities.add((observed.st_dev, observed.st_ino))
|
||||
# Effect directories are protected by prefix without inventorying children.
|
||||
# Effect directories join the prefix set only after the recursive inventory.
|
||||
return frozenset(job_dirs | effect_dirs), frozenset(protected), frozenset(identities)
|
||||
|
||||
|
||||
@@ -137,11 +139,6 @@ def _control_plane_path(path, *, snapshot=None):
|
||||
# A scan-local snapshot bounds repeated hardlink checks. Ordinary resource
|
||||
# resolution always observes fresh state. Neither form is an atomic kernel
|
||||
# access policy, and snapshots must never survive a workspace guard call.
|
||||
# Public state and hardlink denial also applies to sealed resources. Lazy
|
||||
# import avoids coupling inert identity definitions to dispatcher startup.
|
||||
from src.tool_execution import _is_app_state_path, _is_hardlinked_regular_file
|
||||
if _is_sensitive_path(path) or _is_app_state_path(path) or _is_hardlinked_regular_file(path):
|
||||
return True
|
||||
directories, protected, identities = _control_plane_snapshot() if snapshot is None else snapshot
|
||||
if any(Path(path).is_relative_to(directory) for directory in directories) or path in protected:
|
||||
return True
|
||||
|
||||
@@ -496,6 +496,7 @@ def _create_pdf_text_derivative(db, *, source_doc, content: str, owner: Optional
|
||||
"content": clean,
|
||||
"version": 1,
|
||||
"source_doc_id": getattr(source_doc, "id", None),
|
||||
"exit_code": 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -618,6 +619,7 @@ class CreateDocumentTool:
|
||||
"language": language,
|
||||
"content": content,
|
||||
"version": 1,
|
||||
"exit_code": 0,
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@@ -708,6 +710,7 @@ class UpdateDocumentTool:
|
||||
"language": doc.language,
|
||||
"content": new_content,
|
||||
"version": new_ver,
|
||||
"exit_code": 0,
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@@ -863,6 +866,7 @@ class EditDocumentTool:
|
||||
"version": new_ver,
|
||||
"applied": applied,
|
||||
"skipped": skipped,
|
||||
"exit_code": 0,
|
||||
}
|
||||
return {"error": "No edits applied — FIND text cannot be blank"}
|
||||
|
||||
@@ -968,6 +972,7 @@ class EditDocumentTool:
|
||||
"version": new_ver,
|
||||
"applied": applied,
|
||||
"skipped": skipped,
|
||||
"exit_code": 0,
|
||||
}
|
||||
if partial_edits:
|
||||
result.update({
|
||||
@@ -1059,6 +1064,7 @@ class SuggestDocumentTool:
|
||||
"partial": bool(invalid),
|
||||
"rejected": len(invalid),
|
||||
"invalid_suggestions": invalid,
|
||||
"exit_code": 0,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
+114
-563
@@ -3,9 +3,8 @@ import json
|
||||
import os
|
||||
import re
|
||||
import difflib
|
||||
import secrets
|
||||
import fnmatch
|
||||
import shutil
|
||||
import time
|
||||
import tempfile
|
||||
from typing import Optional, Dict, Any, Tuple, List
|
||||
|
||||
@@ -19,8 +18,6 @@ _CODENAV_SKIP_DIRS = frozenset({
|
||||
})
|
||||
_CODENAV_MAX_HITS = 200
|
||||
_CODENAV_MAX_LINE = 400
|
||||
_GREP_TIMEOUT_SECONDS = 20
|
||||
_GREP_STDERR_PREFIX = 20_000
|
||||
_STRUCTURED_DOCUMENT_SUFFIXES = frozenset({
|
||||
".doc", ".docx", ".epub", ".pdf", ".pptx", ".xls", ".xlsx",
|
||||
})
|
||||
@@ -91,79 +88,6 @@ def _glob_to_regex(pat: str) -> "re.Pattern":
|
||||
i += 1
|
||||
return re.compile("".join(out))
|
||||
|
||||
|
||||
def _validate_grep_descriptor(descriptor):
|
||||
"""Revalidate an inert parent snapshot without inherited ContextVars."""
|
||||
path, identity, ancestors = descriptor
|
||||
if os.path.islink(path) or os.path.realpath(path) != path:
|
||||
raise ValueError("grep: resource path changed")
|
||||
for parent, observed in ancestors:
|
||||
info = os.stat(parent, follow_symlinks=False)
|
||||
if not os.path.isdir(parent) or (info.st_dev, info.st_ino) != tuple(observed):
|
||||
raise ValueError("grep: resource ancestor changed")
|
||||
info = os.stat(path, follow_symlinks=False)
|
||||
import stat
|
||||
if (not stat.S_ISREG(info.st_mode) or info.st_nlink > 1
|
||||
or (info.st_dev, info.st_ino) != tuple(identity)):
|
||||
raise ValueError("grep: resource identity changed")
|
||||
|
||||
|
||||
def _python_grep_worker(payload: dict, output_queue) -> None:
|
||||
"""Spawn-safe fallback grep worker used when ripgrep is unavailable.
|
||||
|
||||
Keep this at module scope: a frozen Windows executable cannot safely be
|
||||
relaunched as ``sys.executable -c ...``, while multiprocessing can invoke a
|
||||
top-level target through its frozen-process bootstrap.
|
||||
"""
|
||||
try:
|
||||
flags = re.IGNORECASE if payload["ignore_case"] else 0
|
||||
try:
|
||||
regex = re.compile(payload["pattern"], flags)
|
||||
glob_regex = (
|
||||
_glob_to_regex(payload["glob"].replace("\\", "/"))
|
||||
if payload["glob"]
|
||||
else None
|
||||
)
|
||||
except re.error as exc:
|
||||
output_queue.put(("error", f"grep: bad pattern: {exc}"))
|
||||
return
|
||||
|
||||
max_hits = payload["max_hits"]
|
||||
hits = 0
|
||||
|
||||
for descriptor in payload["files"]:
|
||||
if hits >= max_hits:
|
||||
break
|
||||
path, identity, ancestors = descriptor
|
||||
_validate_grep_descriptor(descriptor)
|
||||
relative = os.path.relpath(path, payload["base"]).replace(os.sep, "/")
|
||||
if glob_regex and not (
|
||||
glob_regex.fullmatch(relative) or glob_regex.fullmatch(os.path.basename(path))
|
||||
):
|
||||
continue
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="strict") as handle:
|
||||
info = os.fstat(handle.fileno())
|
||||
if (info.st_dev, info.st_ino) != tuple(identity) or info.st_nlink > 1:
|
||||
raise ValueError("grep: resource identity changed before read")
|
||||
for number, line in enumerate(handle, 1):
|
||||
if regex.search(line):
|
||||
output_queue.put(("match", path, number, line.rstrip()[:_CODENAV_MAX_LINE]))
|
||||
hits += 1
|
||||
if hits >= max_hits:
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
except OSError as error:
|
||||
output_queue.put(("error", f"grep: {error}"))
|
||||
return
|
||||
output_queue.put(("done",))
|
||||
except BaseException as exc:
|
||||
try:
|
||||
output_queue.put(("error", f"grep: fallback worker failed: {exc}"))
|
||||
except BaseException:
|
||||
pass
|
||||
|
||||
def _unified_diff(old: str, new: str, path: str) -> Optional[Dict[str, Any]]:
|
||||
if old == new:
|
||||
return None
|
||||
@@ -344,82 +268,32 @@ class ReadFileTool:
|
||||
data = data[:MAX_READ_CHARS] + f"\n... [truncated at {MAX_READ_CHARS} chars]"
|
||||
return {"output": data, "exit_code": 0}
|
||||
|
||||
|
||||
def _write_new_file_without_overwrite(path: str, body: str) -> None:
|
||||
"""Publish a new file without exposing a writable placeholder at its path.
|
||||
|
||||
Stage beside the destination, then hard-link it into place. The link is
|
||||
atomic and fails if another writer created the destination first.
|
||||
"""
|
||||
directory = os.path.dirname(path) or "."
|
||||
temporary_path = os.path.join(
|
||||
directory, f".odysseus-write-{secrets.token_hex(16)}.tmp"
|
||||
)
|
||||
fd = os.open(
|
||||
temporary_path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
0o666,
|
||||
)
|
||||
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as temporary_file:
|
||||
fd = None
|
||||
temporary_file.write(body)
|
||||
os.link(temporary_path, path)
|
||||
finally:
|
||||
if fd is not None:
|
||||
os.close(fd)
|
||||
try:
|
||||
os.unlink(temporary_path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
class _EmptyBodyWouldTruncate(Exception):
|
||||
"""Raised inside the write thread when an undeclared empty body is about to
|
||||
replace a file that holds bytes. Carries the size at risk so the caller can be
|
||||
told what it would have lost (#6414)."""
|
||||
|
||||
def __init__(self, path: str, existing_bytes: int):
|
||||
super().__init__(path)
|
||||
self.path = path
|
||||
self.existing_bytes = existing_bytes
|
||||
|
||||
def _parse_write_intent(content: str) -> tuple[str, str, bool, bool]:
|
||||
"""Classify the original transport, before binding or source normalization.
|
||||
|
||||
The returned clear flag is derived here, never from a caller-supplied key.
|
||||
"""
|
||||
if not isinstance(content, str):
|
||||
raise ValueError("write_file: expected string arguments")
|
||||
if content.lstrip().startswith("{"):
|
||||
try:
|
||||
args = json.loads(content)
|
||||
except (TypeError, ValueError) as error:
|
||||
raise ValueError("write_file: expected valid JSON arguments") from error
|
||||
if not isinstance(args, dict):
|
||||
raise ValueError("write_file: expected a JSON object")
|
||||
path, body = args.get("path"), args.get("content")
|
||||
if not isinstance(body, str):
|
||||
raise ValueError("write_file: content required and must be a string")
|
||||
if not isinstance(path, str):
|
||||
raise ValueError("write_file: path required and must be a string")
|
||||
return path.strip(), body, True, not body.strip()
|
||||
path, delimiter, body = content.partition("\n")
|
||||
return path.strip(), body, bool(delimiter), False
|
||||
|
||||
|
||||
class WriteFileTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import _display_tool_path, _resolve_tool_path
|
||||
from src.agent_runtime.resource_binding import active_resource_operation
|
||||
bound = active_resource_operation()
|
||||
original = bound.operation.input if bound is not None else content
|
||||
try:
|
||||
_, _, has_section, declared_clear = _parse_write_intent(original)
|
||||
raw_path, body, _, _ = _parse_write_intent(content)
|
||||
except ValueError as error:
|
||||
return {"error": str(error), "exit_code": 1}
|
||||
from src.tool_execution import _display_tool_path, _resolve_tool_path, _resolve_search_root, _truncate
|
||||
lines = content.split("\n", 1)
|
||||
raw_path = lines[0].strip()
|
||||
body = lines[1] if len(lines) > 1 else ""
|
||||
# Decode JSON-object args (the fenced inline-args shape
|
||||
# ```write_file {"path": "...", "content": "..."}```), matching
|
||||
# ReadFileTool above. Without this the whole JSON string becomes the
|
||||
# path and the file is written under a garbage name. This is the live
|
||||
# path: there is no filesystem MCP server, so write_file always runs
|
||||
# here via _direct_fallback, not through _build_mcp_args.
|
||||
_stripped = content.strip()
|
||||
if _stripped.startswith("{"):
|
||||
try:
|
||||
_a = json.loads(_stripped)
|
||||
if not isinstance(_a, dict):
|
||||
return {"error": "write_file: expected a JSON object", "exit_code": 1}
|
||||
raw_path_value = _a.get("path")
|
||||
body_value = _a.get("content")
|
||||
raw_path = raw_path_value.strip() if isinstance(raw_path_value, str) else ""
|
||||
if not isinstance(body_value, str):
|
||||
return {"error": "write_file: content required", "exit_code": 1}
|
||||
body = body_value
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
return {"error": "write_file: expected valid JSON arguments", "exit_code": 1}
|
||||
if not raw_path:
|
||||
return {"error": "write_file: path required", "exit_code": 1}
|
||||
try:
|
||||
@@ -449,6 +323,16 @@ class WriteFileTool:
|
||||
"exit_code": 1,
|
||||
"artifact_format_error": True,
|
||||
}
|
||||
if not body:
|
||||
return {
|
||||
"error": (
|
||||
f"write_file: {path}: content required; refusing to create an "
|
||||
"empty file. Call write_file again with the exact filename and "
|
||||
"non-empty content. If you need a directory, create it from "
|
||||
"bash/python instead."
|
||||
),
|
||||
"exit_code": 1,
|
||||
}
|
||||
# write_file is a UTF-8 text writer. Refuse to silently destroy an
|
||||
# existing PDF, image, archive, or media artifact produced by a
|
||||
# format-aware tool, especially after the agent has verified it.
|
||||
@@ -463,10 +347,6 @@ class WriteFileTool:
|
||||
"exit_code": 1,
|
||||
"binary_artifact_preserved": target_existed,
|
||||
}
|
||||
if not has_section:
|
||||
return {"error": "write_file: content required; missing content section", "exit_code": 1}
|
||||
if raw_path.endswith(("/", "\\")) or os.path.isdir(path):
|
||||
return {"error": "write_file: target is a directory", "exit_code": 1}
|
||||
# This writer truncates in place. Once that stage is reached, a failure
|
||||
# may leave a partial file; report it so effect evidence stays honest.
|
||||
attempted = []
|
||||
@@ -481,67 +361,21 @@ class WriteFileTool:
|
||||
d = os.path.dirname(path)
|
||||
if d:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
if not body.strip() and not declared_clear:
|
||||
# Empty/whitespace-only writes to an already-empty file are no-ops.
|
||||
# Avoid reopening in truncating mode: another writer may have added
|
||||
# data since the read above.
|
||||
if os.path.isfile(path):
|
||||
existing_bytes = os.path.getsize(path)
|
||||
if existing_bytes > 0:
|
||||
raise _EmptyBodyWouldTruncate(path, existing_bytes)
|
||||
return old, 0
|
||||
|
||||
try:
|
||||
if body:
|
||||
# Publish whitespace content atomically. Writing it
|
||||
# after exclusive creation could overwrite bytes from
|
||||
# a writer that filled the new placeholder meanwhile.
|
||||
attempted.append(True)
|
||||
_write_new_file_without_overwrite(path, body)
|
||||
else:
|
||||
# An exact empty body needs no staged data, so create
|
||||
# the file exclusively and never write through it.
|
||||
attempted.append(True)
|
||||
with open(path, "x", encoding="utf-8"):
|
||||
pass
|
||||
except FileExistsError:
|
||||
if os.path.isfile(path):
|
||||
existing_bytes = os.path.getsize(path)
|
||||
if existing_bytes > 0:
|
||||
raise _EmptyBodyWouldTruncate(path, existing_bytes)
|
||||
return old, 0
|
||||
raise
|
||||
return old, len(body)
|
||||
|
||||
attempted.append(True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(body)
|
||||
return old, len(body)
|
||||
old_content, size = await asyncio.to_thread(_write)
|
||||
except _EmptyBodyWouldTruncate as e:
|
||||
clear_call = json.dumps({"path": raw_path, "content": ""})
|
||||
return {
|
||||
"error": (
|
||||
f"write_file: refused to write an empty body over {e.path} — it holds "
|
||||
f"{e.existing_bytes} bytes, which the write would have destroyed, so "
|
||||
f"the file is unchanged. To clear it on purpose, resend with an "
|
||||
f"explicit empty content: {clear_call}"
|
||||
),
|
||||
"exit_code": 1,
|
||||
}
|
||||
except PermissionError:
|
||||
return {"error": f"write_file: {path}: permission denied", "exit_code": 1,
|
||||
**({"mutation_attempted": True} if attempted else {})}
|
||||
except OSError as e:
|
||||
return {"error": f"write_file: {path}: {e}", "exit_code": 1,
|
||||
**({"mutation_attempted": True} if attempted else {})}
|
||||
committed_body = old_content if size == 0 and not declared_clear and not body.strip() else body
|
||||
diff = _unified_diff(old_content, committed_body, path)
|
||||
diff = _unified_diff(old_content, body, path)
|
||||
result = {
|
||||
"output": (f"Wrote {size} bytes to {_display_tool_path(path)}" if attempted else
|
||||
f"No write performed for {_display_tool_path(path)} (implicit empty body)"),
|
||||
"output": f"Wrote {size} bytes to {_display_tool_path(path)}",
|
||||
"exit_code": 0,
|
||||
**({"write_noop": True} if not attempted else {}),
|
||||
}
|
||||
if diff:
|
||||
result["diff"] = diff
|
||||
@@ -785,7 +619,7 @@ def _apply_patch_hunks(original: str, hunks: List[List[str]], label: str) -> str
|
||||
|
||||
class LsTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import _display_tool_path, _is_denied_tool_path, _resolve_search_root, _truncate
|
||||
from src.tool_execution import _display_tool_path, _resolve_search_root, _truncate
|
||||
raw_path = ""
|
||||
_s = (content or "").strip()
|
||||
if _s.startswith("{"):
|
||||
@@ -809,7 +643,7 @@ class LsTool:
|
||||
for entry in it:
|
||||
if entry.name.startswith("."):
|
||||
continue
|
||||
if _is_denied_tool_path(os.path.realpath(entry.path)) or not _visible_bound_resource(entry.path):
|
||||
if not _visible_bound_resource(entry.path):
|
||||
continue
|
||||
try:
|
||||
is_dir = entry.is_dir(follow_symlinks=False)
|
||||
@@ -838,8 +672,6 @@ class GlobTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import (
|
||||
_SENSITIVE_BASENAMES,
|
||||
_can_traverse_tool_path,
|
||||
_is_denied_tool_path,
|
||||
_display_tool_path,
|
||||
_is_sensitive_path,
|
||||
_resolve_tool_path,
|
||||
@@ -884,7 +716,7 @@ class GlobTool:
|
||||
# .ssh/id_rsa, …) falls through to the walk, which skips it —
|
||||
# otherwise glob would surface secret paths that read_file /
|
||||
# grep already refuse to touch.
|
||||
if inside and os.path.exists(cand) and not _is_denied_tool_path(cand) and _visible_bound_resource(cand):
|
||||
if inside and os.path.exists(cand) and not _is_sensitive_path(cand) and _visible_bound_resource(cand):
|
||||
return [cand], None
|
||||
# Literal not at exact path — fall through to walk so
|
||||
# e.g. "foo.py" still matches at any depth (like rglob).
|
||||
@@ -894,18 +726,13 @@ class GlobTool:
|
||||
cap = _CODENAV_MAX_HITS * 5
|
||||
try:
|
||||
for dp, dns, fns in os.walk(base):
|
||||
if not _can_traverse_tool_path(os.path.realpath(dp)):
|
||||
dns[:] = []
|
||||
continue
|
||||
# Prune skipped dirs before descending (unlike rglob which
|
||||
# descends first then filters — fatal on large node_modules).
|
||||
# Sensitive dirs (.ssh, .gnupg, …) are pruned too so glob
|
||||
# never enumerates the keys/tokens inside them.
|
||||
dns[:] = [
|
||||
d for d in dns
|
||||
if d not in _CODENAV_SKIP_DIRS
|
||||
and d not in _SENSITIVE_BASENAMES
|
||||
and _can_traverse_tool_path(os.path.realpath(os.path.join(dp, d)))
|
||||
if d not in _CODENAV_SKIP_DIRS and d not in _SENSITIVE_BASENAMES
|
||||
]
|
||||
for name in fns + dns:
|
||||
full = os.path.join(dp, name)
|
||||
@@ -913,7 +740,7 @@ class GlobTool:
|
||||
if regex.fullmatch(rel) or regex.fullmatch(name):
|
||||
# Skip deny-listed sensitive files (.env, id_rsa,
|
||||
# known_hosts, …) the same way grep does.
|
||||
if _is_denied_tool_path(os.path.realpath(full)) or not _visible_bound_resource(full):
|
||||
if _is_sensitive_path(os.path.realpath(full)) or not _visible_bound_resource(full):
|
||||
continue
|
||||
try:
|
||||
mtime = os.stat(full).st_mtime
|
||||
@@ -940,14 +767,10 @@ class GlobTool:
|
||||
class GrepTool:
|
||||
async def execute(self, content: str, ctx: dict) -> dict:
|
||||
from src.tool_execution import (
|
||||
_SENSITIVE_BASENAMES,
|
||||
_SENSITIVE_FILE_PATTERNS,
|
||||
_agent_readable_data_subdirs,
|
||||
_is_denied_tool_path,
|
||||
_display_tool_path,
|
||||
_can_traverse_tool_path,
|
||||
_is_sensitive_path,
|
||||
_path_within,
|
||||
_resolve_tool_path,
|
||||
_resolve_search_root,
|
||||
_truncate,
|
||||
)
|
||||
@@ -976,355 +799,83 @@ class GrepTool:
|
||||
return {"error": f"grep: {e}", "exit_code": 1}
|
||||
|
||||
def _grep():
|
||||
import multiprocessing
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
from src.constants import DATA_DIR
|
||||
|
||||
rg = shutil.which("rg")
|
||||
real_root = os.path.realpath(root)
|
||||
deadline = time.monotonic() + _GREP_TIMEOUT_SECONDS
|
||||
base = real_root if os.path.isdir(real_root) else os.path.dirname(real_root)
|
||||
import re as _re
|
||||
import shutil
|
||||
from src.agent_runtime.resource_binding import active_resource_operation
|
||||
bound = active_resource_operation()
|
||||
if bound is not None:
|
||||
bound.validate()
|
||||
files = []
|
||||
|
||||
def check_deadline():
|
||||
if time.monotonic() >= deadline:
|
||||
raise TimeoutError("grep: timed out")
|
||||
|
||||
def observe_file(path):
|
||||
check_deadline()
|
||||
if os.path.islink(path):
|
||||
return
|
||||
canonical = os.path.realpath(path)
|
||||
if not _path_within(canonical, base) or _is_denied_tool_path(canonical):
|
||||
return
|
||||
if bound is not None:
|
||||
try:
|
||||
bound.resolve_path(canonical)
|
||||
except ValueError:
|
||||
# Intentionally denied publication control-plane paths
|
||||
# are omitted before any producer is allowed to read.
|
||||
return
|
||||
info = os.stat(canonical, follow_symlinks=False)
|
||||
if not os.path.isfile(canonical):
|
||||
return
|
||||
ancestors = []
|
||||
parent = os.path.dirname(canonical)
|
||||
while _path_within(parent, base):
|
||||
observed = os.stat(parent, follow_symlinks=False)
|
||||
ancestors.append((parent, (observed.st_dev, observed.st_ino)))
|
||||
if parent == base:
|
||||
break
|
||||
parent = os.path.dirname(parent)
|
||||
files.append((canonical, (info.st_dev, info.st_ino), tuple(ancestors)))
|
||||
if len(files) > 100_000:
|
||||
raise ValueError("grep: enumeration limit exceeded; scan incomplete")
|
||||
|
||||
try:
|
||||
if os.path.islink(root):
|
||||
raise ValueError("grep: symlink search root is not allowed")
|
||||
if os.path.isfile(real_root):
|
||||
observe_file(real_root)
|
||||
elif os.path.isdir(real_root):
|
||||
pending_directories = [real_root]
|
||||
enumerated = 0
|
||||
while pending_directories:
|
||||
check_deadline()
|
||||
directory = pending_directories.pop()
|
||||
if not _can_traverse_tool_path(directory):
|
||||
continue
|
||||
with os.scandir(directory) as entries:
|
||||
for entry in entries:
|
||||
check_deadline()
|
||||
enumerated += 1
|
||||
if enumerated > 100_000:
|
||||
raise ValueError("grep: enumeration limit exceeded; scan incomplete")
|
||||
if entry.is_symlink():
|
||||
continue
|
||||
canonical = os.path.realpath(entry.path)
|
||||
if not _path_within(canonical, base):
|
||||
raise ValueError("grep: directory identity changed during enumeration")
|
||||
if entry.is_dir(follow_symlinks=False):
|
||||
if (entry.name not in _CODENAV_SKIP_DIRS
|
||||
and _can_traverse_tool_path(canonical)
|
||||
and (bound is None or _is_denied_tool_path(canonical)
|
||||
or _visible_bound_resource(canonical))):
|
||||
pending_directories.append(canonical)
|
||||
else:
|
||||
observe_file(entry.path)
|
||||
else:
|
||||
raise FileNotFoundError(f"grep: {root}: not found")
|
||||
check_deadline()
|
||||
except (OSError, ValueError) as error:
|
||||
return None, str(error) if str(error).startswith("grep:") else f"grep: {error}"
|
||||
descriptors = {record[0]: record for record in files}
|
||||
targets = list(descriptors)
|
||||
lines: list[str] = []
|
||||
|
||||
def parse_rg_result(raw: str) -> Optional[str]:
|
||||
try:
|
||||
record = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return None
|
||||
if record.get("type") != "match":
|
||||
return None
|
||||
data = record.get("data") or {}
|
||||
path = (data.get("path") or {}).get("text")
|
||||
text_value = (data.get("lines") or {}).get("text")
|
||||
number = data.get("line_number")
|
||||
if not isinstance(path, str) or not isinstance(text_value, str):
|
||||
return None
|
||||
absolute = path if os.path.isabs(path) else os.path.join(base, path)
|
||||
canonical = os.path.realpath(absolute)
|
||||
if canonical not in descriptors:
|
||||
raise ValueError("grep: producer returned an undeclared resource")
|
||||
_validate_grep_descriptor(descriptors[canonical])
|
||||
if bound is not None:
|
||||
bound.resolve_path(canonical)
|
||||
return f"{_display_tool_path(canonical)}:{number}:{text_value.rstrip()[:_CODENAV_MAX_LINE]}"
|
||||
|
||||
def run_rg(cmd: list[str]) -> Optional[str]:
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
cwd=base,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
except Exception as exc:
|
||||
return f"grep: {exc}"
|
||||
output: queue.Queue[Optional[str]] = queue.Queue(maxsize=max_hits + 2)
|
||||
stderr_prefix: list[str] = []
|
||||
stderr_size = 0
|
||||
stop_reader = threading.Event()
|
||||
|
||||
def enqueue_stdout(value: Optional[str]) -> bool:
|
||||
# The consumer stops at the result cap or deadline. Never
|
||||
# leave a producer blocked on its bounded queue afterward.
|
||||
while not stop_reader.is_set():
|
||||
try:
|
||||
output.put(value, timeout=0.05)
|
||||
return True
|
||||
except queue.Full:
|
||||
continue
|
||||
return False
|
||||
|
||||
def read_stdout() -> None:
|
||||
assert process.stdout is not None
|
||||
try:
|
||||
for line in process.stdout:
|
||||
if not enqueue_stdout(line.rstrip("\n")):
|
||||
break
|
||||
finally:
|
||||
enqueue_stdout(None)
|
||||
|
||||
def read_stderr() -> None:
|
||||
nonlocal stderr_size
|
||||
assert process.stderr is not None
|
||||
while True:
|
||||
chunk = process.stderr.read(4096)
|
||||
if not chunk:
|
||||
break
|
||||
if stderr_size < _GREP_STDERR_PREFIX:
|
||||
kept = chunk[:_GREP_STDERR_PREFIX - stderr_size]
|
||||
stderr_prefix.append(kept)
|
||||
stderr_size += len(kept)
|
||||
|
||||
stdout_thread = threading.Thread(target=read_stdout, daemon=True)
|
||||
stderr_thread = threading.Thread(target=read_stderr, daemon=True)
|
||||
stdout_thread.start()
|
||||
stderr_thread.start()
|
||||
timed_out = False
|
||||
capped = False
|
||||
try:
|
||||
while len(lines) < max_hits:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
timed_out = True
|
||||
break
|
||||
try:
|
||||
raw = output.get(timeout=remaining)
|
||||
except queue.Empty:
|
||||
timed_out = True
|
||||
break
|
||||
if raw is None:
|
||||
break
|
||||
parsed = parse_rg_result(raw)
|
||||
if parsed and parsed not in lines:
|
||||
lines.append(parsed)
|
||||
capped = len(lines) >= max_hits
|
||||
finally:
|
||||
stop_reader.set()
|
||||
if (timed_out or capped) and process.poll() is None:
|
||||
process.terminate()
|
||||
try:
|
||||
remaining = max(0.01, deadline - time.monotonic())
|
||||
return_code = process.wait(timeout=min(1, remaining))
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
return_code = process.wait()
|
||||
stdout_thread.join()
|
||||
stderr_thread.join()
|
||||
if timed_out:
|
||||
return "grep: timed out"
|
||||
if not capped and return_code not in (0, 1):
|
||||
detail = "".join(stderr_prefix).strip()
|
||||
return f"grep: {detail or f'process exited {return_code}'}"
|
||||
return None
|
||||
|
||||
if not os.path.exists(root):
|
||||
return None, f"grep: search target not found: {_display_tool_path(root)}"
|
||||
# The pathname-only fast path scans before individual resources can
|
||||
# be checked. Bound searches must validate every file before read.
|
||||
rg = None if active_resource_operation() is not None else shutil.which("rg")
|
||||
if rg:
|
||||
cmd = [rg, "--line-number", "--with-filename", "--no-heading", "--color=never",
|
||||
"--max-count", str(max_hits)]
|
||||
if ignore_case:
|
||||
cmd.append("--ignore-case")
|
||||
if glob_pat:
|
||||
try:
|
||||
glob_regex = _glob_to_regex(glob_pat.replace("\\", "/"))
|
||||
targets = [
|
||||
target for target in targets
|
||||
if glob_regex.fullmatch(os.path.relpath(target, base).replace(os.sep, "/"))
|
||||
or glob_regex.fullmatch(os.path.basename(target))
|
||||
]
|
||||
except re.error:
|
||||
pass
|
||||
# Validate even when policy filtering leaves no search targets.
|
||||
if not targets:
|
||||
cmd_probe = [rg, "--json", "--no-config"]
|
||||
if glob_pat:
|
||||
cmd_probe += ["--glob", glob_pat]
|
||||
cmd_probe += ["--regexp", pattern, "--", "-"]
|
||||
error = run_rg(cmd_probe)
|
||||
return (None, error) if error else ([], None)
|
||||
relative_targets = [os.path.relpath(target, base) for target in targets]
|
||||
for offset in range(0, len(relative_targets), 128):
|
||||
if len(lines) >= max_hits:
|
||||
break
|
||||
cmd = [
|
||||
rg, "--json", "--no-config", "--no-follow",
|
||||
"--max-count", str(max_hits - len(lines)),
|
||||
"--max-columns", str(_CODENAV_MAX_LINE),
|
||||
"--max-columns-preview",
|
||||
]
|
||||
if ignore_case:
|
||||
cmd.append("--ignore-case")
|
||||
if glob_pat:
|
||||
cmd += ["--glob", glob_pat]
|
||||
for sensitive_pattern in _SENSITIVE_FILE_PATTERNS:
|
||||
cmd += ["--iglob", f"!{sensitive_pattern}"]
|
||||
for skipped_dir in _CODENAV_SKIP_DIRS:
|
||||
cmd += ["--glob", f"!**/{skipped_dir}/**"]
|
||||
cmd += ["--regexp", pattern, "--", *relative_targets[offset:offset + 128]]
|
||||
try:
|
||||
for path in targets[offset:offset + 128]:
|
||||
check_deadline()
|
||||
_validate_grep_descriptor(descriptors[path])
|
||||
if bound is not None:
|
||||
bound.resolve_path(path)
|
||||
error = run_rg(cmd)
|
||||
except (OSError, ValueError) as exc:
|
||||
return None, f"grep: {exc}"
|
||||
if error:
|
||||
return None, error
|
||||
return lines, None
|
||||
|
||||
# This runs inside asyncio.to_thread(), so forking would clone a
|
||||
# multithreaded process and can deadlock. Spawn is platform-safe and
|
||||
# PyInstaller-compatible via launcher's early freeze_support().
|
||||
payload = {
|
||||
"root": base,
|
||||
"base": base,
|
||||
"files": tuple(files),
|
||||
"pattern": pattern,
|
||||
"ignore_case": ignore_case,
|
||||
"glob": glob_pat,
|
||||
"max_hits": max_hits,
|
||||
"skip_dirs": tuple(_CODENAV_SKIP_DIRS),
|
||||
"sensitive_names": tuple(
|
||||
set(_SENSITIVE_BASENAMES) | set(_SENSITIVE_FILE_PATTERNS)
|
||||
),
|
||||
}
|
||||
try:
|
||||
context = multiprocessing.get_context("spawn")
|
||||
output_queue = context.Queue(maxsize=max_hits + 2)
|
||||
worker = context.Process(
|
||||
target=_python_grep_worker, args=(payload, output_queue)
|
||||
)
|
||||
worker.start()
|
||||
except Exception as exc:
|
||||
cmd += ["--glob", glob_pat]
|
||||
# --iglob (not --glob) so the exclusion is case-insensitive:
|
||||
# on a case-insensitive filesystem "ID_RSA"/"Known_Hosts"
|
||||
# resolve to the same secret as their lowercase forms, and the
|
||||
# Python fallback below already folds case via _is_sensitive_path.
|
||||
for _pat in _SENSITIVE_FILE_PATTERNS:
|
||||
cmd += ["--iglob", f"!*{_pat}*"]
|
||||
for _d in _CODENAV_SKIP_DIRS:
|
||||
cmd += ["--glob", f"!**/{_d}/**"]
|
||||
cmd += ["--regexp", pattern, root]
|
||||
try:
|
||||
output_queue.close()
|
||||
except (NameError, OSError, ValueError):
|
||||
pass
|
||||
return None, f"grep: could not start fallback worker: {exc}"
|
||||
error = None
|
||||
completed = False
|
||||
import subprocess
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
||||
# ripgrep: 0 = matches, 1 = no matches, 2 = failed scan.
|
||||
# Do not present invalid patterns or IO failures as absence.
|
||||
if p.returncode not in (0, 1):
|
||||
detail = (p.stderr or '').strip()[:1200]
|
||||
return None, f"grep: search failed (exit {p.returncode}): {detail or 'no diagnostic available'}"
|
||||
lines = [ln for ln in (p.stdout or "").splitlines() if ln][:max_hits]
|
||||
return lines, None
|
||||
except subprocess.TimeoutExpired:
|
||||
return None, "grep: timed out"
|
||||
except Exception as _e:
|
||||
return None, f"grep: {_e}"
|
||||
try:
|
||||
while len(lines) < max_hits:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
error = "grep: timed out"
|
||||
break
|
||||
try:
|
||||
# Keep queue waits short enough to observe a spawn
|
||||
# worker that dies during bootstrap/import before it
|
||||
# can enqueue either an error or the done sentinel.
|
||||
record = output_queue.get(timeout=min(0.05, remaining))
|
||||
except queue.Empty:
|
||||
if worker.is_alive():
|
||||
rx = _re.compile(pattern, _re.IGNORECASE if ignore_case else 0)
|
||||
except _re.error as _e:
|
||||
return None, f"grep: bad pattern: {_e}"
|
||||
hits = []
|
||||
scan_errors = []
|
||||
if os.path.isfile(root):
|
||||
file_iter = [root]
|
||||
else:
|
||||
file_iter = []
|
||||
for dp, dns, fns in os.walk(root, onerror=scan_errors.append):
|
||||
dns[:] = [d for d in dns if d not in _CODENAV_SKIP_DIRS]
|
||||
for fn in fns:
|
||||
if glob_pat and not fnmatch.fnmatch(fn, glob_pat):
|
||||
continue
|
||||
worker.join(timeout=0)
|
||||
try:
|
||||
# A multiprocessing queue's feeder can make the
|
||||
# final record visible at process-exit time. Give
|
||||
# that record precedence over the exit status.
|
||||
remaining = deadline - time.monotonic()
|
||||
record = output_queue.get(
|
||||
timeout=min(0.05, max(0, remaining))
|
||||
)
|
||||
except queue.Empty:
|
||||
error = f"grep: fallback worker exited {worker.exitcode}"
|
||||
break
|
||||
if record[0] == "done":
|
||||
completed = True
|
||||
break
|
||||
if record[0] == "error":
|
||||
error = record[1]
|
||||
break
|
||||
_, path, number, text_value = record
|
||||
canonical = os.path.realpath(path)
|
||||
if canonical not in descriptors:
|
||||
error = "grep: fallback returned an undeclared resource"
|
||||
break
|
||||
try:
|
||||
_validate_grep_descriptor(descriptors[canonical])
|
||||
if bound is not None:
|
||||
bound.resolve_path(canonical)
|
||||
except (OSError, ValueError) as exc:
|
||||
error = f"grep: {exc}"
|
||||
break
|
||||
rendered = f"{_display_tool_path(canonical)}:{number}:{text_value}"
|
||||
if rendered not in lines:
|
||||
lines.append(rendered)
|
||||
finally:
|
||||
if completed:
|
||||
worker.join(timeout=min(1, max(0.01, deadline - time.monotonic())))
|
||||
if worker.is_alive():
|
||||
worker.terminate()
|
||||
worker.join(timeout=1)
|
||||
if worker.is_alive():
|
||||
worker.kill()
|
||||
worker.join()
|
||||
output_queue.close()
|
||||
if error:
|
||||
return None, error
|
||||
if (not completed or worker.exitcode not in (0, None)) and len(lines) < max_hits:
|
||||
return None, f"grep: fallback worker exited {worker.exitcode}"
|
||||
return lines, None
|
||||
file_iter.append(os.path.join(dp, fn))
|
||||
for fp in file_iter:
|
||||
if len(hits) >= max_hits:
|
||||
break
|
||||
try:
|
||||
resolved_file = _resolve_tool_path(fp)
|
||||
except ValueError:
|
||||
# Apply the same workspace/sensitive-path checks to each
|
||||
# discovered file, not just the initial search directory.
|
||||
continue
|
||||
try:
|
||||
with open(resolved_file, "r", encoding="utf-8", errors="strict") as f:
|
||||
for i, line in enumerate(f, 1):
|
||||
if rx.search(line):
|
||||
hits.append(f"{fp}:{i}:{line.rstrip()[:_CODENAV_MAX_LINE]}")
|
||||
if len(hits) >= max_hits:
|
||||
break
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
except OSError as error:
|
||||
scan_errors.append(error)
|
||||
if scan_errors:
|
||||
return None, "grep: search incomplete; one or more files or directories could not be read"
|
||||
return hits, None
|
||||
|
||||
lines, err = await asyncio.to_thread(_grep)
|
||||
if err:
|
||||
|
||||
@@ -11,7 +11,7 @@ import sys
|
||||
import time
|
||||
import json
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse, urlsplit, urlunsplit
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -76,14 +76,12 @@ def _resolve_fontfile_for_text(text: str) -> str:
|
||||
async def _cancel_host_shell_bridge_request(
|
||||
url: str, token: str, request_id: str,
|
||||
) -> None:
|
||||
if not token or not is_host_shell_bridge_url_allowed(url):
|
||||
return
|
||||
target = host_shell_bridge_endpoint_url(url, "/cancel")
|
||||
base = url.rsplit("/", 1)[0]
|
||||
try:
|
||||
timeout = httpx.Timeout(5.0, connect=2.0, write=2.0, pool=2.0)
|
||||
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
await client.post(
|
||||
target,
|
||||
f"{base}/cancel",
|
||||
json={"request_id": request_id},
|
||||
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
||||
)
|
||||
@@ -218,12 +216,6 @@ def is_host_shell_bridge_url_allowed(url: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def host_shell_bridge_endpoint_url(url: str, path: str) -> str:
|
||||
"""Replace a validated bridge URL's path without changing its authority."""
|
||||
parsed = urlsplit(url)
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, path, "", ""))
|
||||
|
||||
|
||||
def _replace_workspace_alias(content: str, cwd: str) -> str:
|
||||
"""Map virtual /workspace paths without corrupting absolute host paths."""
|
||||
return re.sub(
|
||||
@@ -690,7 +682,6 @@ class HostShellTool:
|
||||
return {"error": "host_shell: invalid bridge URL", "exit_code": 1}
|
||||
if not token:
|
||||
return {"error": "host_shell: bridge token missing", "exit_code": 1}
|
||||
run_url = host_shell_bridge_endpoint_url(url, "/run")
|
||||
|
||||
job_id = str(args.get("job_id") or "").strip()
|
||||
if not command and not job_id:
|
||||
@@ -725,9 +716,9 @@ class HostShellTool:
|
||||
request_body["request_id"] = request_id
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout + 5, trust_env=False) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout + 5) as client:
|
||||
resp = await client.post(
|
||||
run_url,
|
||||
url,
|
||||
json=request_body,
|
||||
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
||||
)
|
||||
@@ -755,7 +746,7 @@ class HostShellTool:
|
||||
while time.monotonic() < deadline:
|
||||
await asyncio.sleep(0.25)
|
||||
poll = await client.post(
|
||||
run_url,
|
||||
url,
|
||||
json={"job_id": auto_job_id},
|
||||
headers={"X-Odysseus-TUI-Bridge-Token": token},
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import tempfile
|
||||
import time
|
||||
import html
|
||||
import hashlib
|
||||
import pwd
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
@@ -31,7 +32,10 @@ _ACTIVE_BROWSER_SESSIONS: set[str] = set()
|
||||
def _service_home() -> Path:
|
||||
"""Return the account home even when a task overrides ``HOME``."""
|
||||
|
||||
return platform_compat.service_home()
|
||||
try:
|
||||
return Path(pwd.getpwuid(os.getuid()).pw_dir)
|
||||
except (KeyError, OSError):
|
||||
return Path.home()
|
||||
|
||||
|
||||
def _host_npm_roots() -> list[Path]:
|
||||
|
||||
+60
-19
@@ -464,8 +464,6 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
return {"error": "Edit needs line 2: memory_id, line 3: new text"}
|
||||
memory_id = lines[1].strip()
|
||||
new_text = lines[2].strip()
|
||||
if not memory_id:
|
||||
return {"error": "Edit needs line 2: memory_id"}
|
||||
if not new_text:
|
||||
return {"error": "New text cannot be empty"}
|
||||
|
||||
@@ -499,8 +497,6 @@ async def do_manage_memory(content: str, session_id: Optional[str] = None, owner
|
||||
if len(lines) < 2:
|
||||
return {"error": "Delete needs line 2: memory_id"}
|
||||
memory_id = lines[1].strip()
|
||||
if not memory_id:
|
||||
return {"error": "Delete needs line 2: memory_id"}
|
||||
|
||||
memories = _memory_manager.load_all()
|
||||
original_len = len(memories)
|
||||
@@ -1155,7 +1151,50 @@ async def do_ui_control(content: str, session_id: Optional[str] = None, owner: O
|
||||
# Image generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None) -> Dict:
|
||||
# Message used whenever a generative image edit (object add/remove/replace,
|
||||
# image generation) is requested but no image provider is configured. The
|
||||
# photo-editor agent and the image tools surface this verbatim so the user gets
|
||||
# a plain, actionable explanation instead of a silent substitute edit.
|
||||
IMAGE_PROVIDER_REQUIRED_MESSAGE = (
|
||||
"Removing/adding objects needs an image generation provider; enable one in "
|
||||
"Settings > Models."
|
||||
)
|
||||
|
||||
|
||||
def image_editing_provider_available(owner: Optional[str] = None) -> bool:
|
||||
"""True when an enabled, owner-visible image endpoint can serve AI edits.
|
||||
|
||||
Generative operations (object add/remove/replace, image generation) require
|
||||
a configured image model. Local transforms (remove-bg, upscale, denoise,
|
||||
enhance-face) do not, but the photo-editor agent gates the whole generative
|
||||
request on this so it never falls back to deterministic approximations.
|
||||
"""
|
||||
try:
|
||||
from src.settings import load_settings
|
||||
|
||||
if not load_settings().get("image_gen_enabled", True):
|
||||
return False
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from src.database import SessionLocal, ModelEndpoint
|
||||
from src.auth_helpers import owner_filter
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
q = db.query(ModelEndpoint).filter(
|
||||
ModelEndpoint.is_enabled == True, # noqa: E712
|
||||
ModelEndpoint.model_type == "image",
|
||||
)
|
||||
q = owner_filter(q, ModelEndpoint, owner)
|
||||
return q.first() is not None
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def do_generate_image(content: str, session_id: Optional[str] = None, owner: Optional[str] = None, *, incognito: bool = False) -> Dict:
|
||||
"""Generate an image using an image-capable model (e.g. gpt-image-1).
|
||||
|
||||
Content format:
|
||||
@@ -1314,7 +1353,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
else:
|
||||
payload["quality"] = "medium"
|
||||
|
||||
logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={prompt[:80]}")
|
||||
logger.info(f"Image generation: model={model_id}, size={size}, quality={quality}, prompt={'[private]' if incognito else prompt[:80]}")
|
||||
|
||||
try:
|
||||
# GPT image models can take 30-120s+ depending on quality
|
||||
@@ -1365,15 +1404,19 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
logger.warning(f"Failed to save gallery record: {_ge}")
|
||||
return ""
|
||||
|
||||
# GPT image models always return b64_json; DALL-E may return url
|
||||
if img.get("b64_json"):
|
||||
def _store_image(image_bytes):
|
||||
if incognito:
|
||||
return 'data:image/png;base64,' + base64.b64encode(image_bytes).decode('ascii'), None
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
img_path = img_dir / filename
|
||||
img_path.write_bytes(base64.b64decode(img.get("b64_json")))
|
||||
image_url = f"/api/generated-image/{filename}"
|
||||
image_id = _save_to_gallery(filename)
|
||||
img_path.write_bytes(image_bytes)
|
||||
return f"/api/generated-image/{filename}", _save_to_gallery(filename)
|
||||
|
||||
# Private results stay in the response, never in the gallery or on disk.
|
||||
if img.get("b64_json"):
|
||||
image_url, image_id = _store_image(base64.b64decode(img['b64_json']))
|
||||
|
||||
elif img.get("url"):
|
||||
# Download external URL and save locally (DALL-E returns temp URLs)
|
||||
@@ -1387,13 +1430,7 @@ async def do_generate_image(content: str, session_id: Optional[str] = None, owne
|
||||
try:
|
||||
dl_resp = httpx.get(result_url, timeout=60)
|
||||
if dl_resp.status_code == 200:
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}.png"
|
||||
img_path = img_dir / filename
|
||||
img_path.write_bytes(dl_resp.content)
|
||||
image_url = f"/api/generated-image/{filename}"
|
||||
image_id = _save_to_gallery(filename)
|
||||
image_url, image_id = _store_image(dl_resp.content)
|
||||
else:
|
||||
image_url = result_url # fallback to external URL
|
||||
except Exception as _dl_e:
|
||||
@@ -1430,6 +1467,7 @@ async def do_edit_image(
|
||||
size: str = "auto",
|
||||
quality: str = "medium",
|
||||
progress_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
incognito: bool = False,
|
||||
) -> Dict:
|
||||
"""Edit an uploaded image using the configured image endpoint."""
|
||||
import base64
|
||||
@@ -1503,7 +1541,7 @@ async def do_edit_image(
|
||||
request_id = uuid.uuid4().hex
|
||||
payload["request_id"] = request_id
|
||||
|
||||
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, path.name, prompt[:80])
|
||||
logger.info("Image edit: model=%s, size=%s, quality=%s, image=%s, prompt=%s", model_id, size, quality, '[private]' if incognito else path.name, '[private]' if incognito else prompt[:80])
|
||||
|
||||
def _save_edited_image_to_gallery(filename: str) -> str:
|
||||
try:
|
||||
@@ -1533,6 +1571,9 @@ async def do_edit_image(
|
||||
from PIL import Image
|
||||
with Image.open(BytesIO(image_bytes)) as output:
|
||||
size = f"{output.width}x{output.height}"
|
||||
image_mime = Image.MIME.get(output.format, 'image/png')
|
||||
if incognito:
|
||||
return 'data:' + image_mime + ';base64,' + base64.b64encode(image_bytes).decode('ascii'), ''
|
||||
img_dir = Path(GENERATED_IMAGES_DIR)
|
||||
img_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{uuid.uuid4().hex[:12]}{suffix}"
|
||||
|
||||
+2
-31
@@ -2,11 +2,10 @@
|
||||
"""Initialize all application components and dependencies."""
|
||||
import os
|
||||
import logging
|
||||
import stat
|
||||
from typing import Dict, Any
|
||||
|
||||
from src.constants import (
|
||||
DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR, AGENT_WORKSPACE_DIR,
|
||||
DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR,
|
||||
SESSIONS_FILE, DEFAULT_HOST, OPENAI_API_KEY
|
||||
)
|
||||
from src.memory import MemoryManager
|
||||
@@ -31,35 +30,7 @@ def create_directories():
|
||||
"""Create necessary directories if they don't exist."""
|
||||
for directory in (DATA_DIR, PERSONAL_DIR, RUNBOOK_DIR, UPLOAD_DIR):
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
|
||||
# The model-controlled workspace must be a real child of DATA_DIR. Never
|
||||
# follow a pre-existing symlink here: it would silently move the default
|
||||
# native-file root outside the application volume before any resolver runs.
|
||||
data_root = os.path.realpath(os.path.abspath(os.path.expanduser(DATA_DIR)))
|
||||
workspace = os.path.abspath(os.path.expanduser(AGENT_WORKSPACE_DIR))
|
||||
expected_workspace = os.path.join(data_root, "agent_workspace")
|
||||
# Validate the real parent so a supported DATA_DIR bind/symlink works, but
|
||||
# require the fixed internal carve-out name and reject a link at the model-
|
||||
# controlled workspace entry itself.
|
||||
if (
|
||||
os.path.basename(workspace) != "agent_workspace"
|
||||
or os.path.realpath(os.path.dirname(workspace)) != data_root
|
||||
):
|
||||
raise RuntimeError("agent workspace must be the canonical child of DATA_DIR")
|
||||
if os.path.lexists(workspace):
|
||||
mode = os.lstat(workspace).st_mode
|
||||
if stat.S_ISLNK(mode) or not stat.S_ISDIR(mode):
|
||||
raise RuntimeError("agent workspace must be a real directory")
|
||||
else:
|
||||
os.mkdir(workspace, 0o700)
|
||||
resolved_workspace = os.path.realpath(workspace)
|
||||
if resolved_workspace != expected_workspace:
|
||||
raise RuntimeError("agent workspace must be the canonical child of DATA_DIR")
|
||||
try:
|
||||
os.chmod(workspace, 0o700)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def initialize_managers(base_dir: str, rag_manager=None) -> Dict[str, Any]:
|
||||
"""
|
||||
Initialize all manager and handler instances.
|
||||
|
||||
@@ -17,10 +17,13 @@ def compact_browser_observation(value, budget=8000):
|
||||
notices.append('Exit code: ' + str(item['exit_code']))
|
||||
if item.get('success') is False:
|
||||
notices.append('Browser command failed.')
|
||||
snapshot = item.get('snapshot') or item.get('text')
|
||||
if item.get('title'):
|
||||
notices.append('Title: ' + str(item['title']))
|
||||
snapshot = item.get('snapshot')
|
||||
if not isinstance(snapshot, str) or not snapshot.strip():
|
||||
snapshot = item.get('text')
|
||||
url = item.get('url') or item.get('origin')
|
||||
title = item.get('title')
|
||||
if isinstance(title, str) and title.strip():
|
||||
notices.append('Title: ' + title.strip())
|
||||
if isinstance(snapshot, str) and snapshot.strip():
|
||||
# Snapshot text already contains labels and refs in DOM order.
|
||||
# The refs mapping repeats them and buries menus in raw JSON.
|
||||
|
||||
+469
-354
File diff suppressed because it is too large
Load Diff
@@ -105,7 +105,7 @@ def auto_compact_threshold_percent() -> int:
|
||||
return max(50, min(95, value))
|
||||
|
||||
# Cursor-style self-summarization prompt — produces structured, dense summaries
|
||||
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation to preserve context after compaction. Produce a structured summary that lets the conversation continue seamlessly.
|
||||
SELF_SUMMARY_SYSTEM_PROMPT = """You are summarizing a conversation with Ajax, the user's AI assistant, to preserve context after compaction. Produce a structured summary that lets Ajax continue seamlessly. Ajax is the assistant's name; Odysseus is the app name.
|
||||
|
||||
Use this format:
|
||||
|
||||
|
||||
+3
-13
@@ -3249,7 +3249,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
||||
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502, "fallback_eligible": False})}\n\n'
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama stream error: {e}")
|
||||
yield f'event: error\ndata: {json.dumps({"error": _stream_failure_message(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||
return
|
||||
|
||||
# ── Anthropic streaming ──
|
||||
@@ -3402,7 +3402,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
||||
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502, "fallback_eligible": False})}\n\n'
|
||||
except Exception as e:
|
||||
logger.error(f"Anthropic stream error: {e}")
|
||||
yield f'event: error\ndata: {json.dumps({"error": _stream_failure_message(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||
return
|
||||
|
||||
# ── OpenAI-compatible streaming ──
|
||||
@@ -3872,17 +3872,7 @@ async def _stream_llm_inner(url: str, model: str, messages: List[Dict], temperat
|
||||
yield f'event: error\ndata: {json.dumps({"error": "Network error", "status": 502, "fallback_eligible": False})}\n\n'
|
||||
except Exception as e:
|
||||
logger.error(f"Stream error: {e}")
|
||||
yield f'event: error\ndata: {json.dumps({"error": _stream_failure_message(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||
|
||||
|
||||
def _stream_failure_message(error: BaseException) -> str:
|
||||
"""Client-facing text for an unexpected streaming failure.
|
||||
|
||||
The raw exception can carry request URLs, local paths or provider internals;
|
||||
callers log it server-side and stream only this generic message, like the
|
||||
named transport failures above it.
|
||||
"""
|
||||
return f"Model stream failed ({type(error).__name__})"
|
||||
yield f'event: error\ndata: {json.dumps({"error": str(e), "status": 502, "fallback_eligible": False})}\n\n'
|
||||
|
||||
|
||||
def _summarize_stream_error(err_chunk: Optional[str]) -> str:
|
||||
|
||||
@@ -102,15 +102,15 @@ class MemoryVectorStore:
|
||||
return collections
|
||||
|
||||
def add(self, memory_id: str, text: str):
|
||||
"""Add a single memory entry to the vector index."""
|
||||
"""Insert or refresh a memory entry, preserving unchanged embeddings."""
|
||||
if not self._healthy:
|
||||
return
|
||||
for lane in self._lanes:
|
||||
try:
|
||||
existing = lane.collection.get(ids=[memory_id])
|
||||
if existing["ids"]:
|
||||
existing = lane.collection.get(ids=[memory_id], include=["documents"])
|
||||
if existing["ids"] and existing["documents"] == [text]:
|
||||
continue
|
||||
lane.collection.add(
|
||||
lane.collection.upsert(
|
||||
ids=[memory_id],
|
||||
embeddings=lane.encode([text]),
|
||||
documents=[text],
|
||||
|
||||
@@ -56,7 +56,7 @@ def supports_user_thinking_toggle(value: object) -> bool:
|
||||
return False
|
||||
return any(pattern in leaf for pattern in (
|
||||
"kimi-k2.5", "kimi-k2.6", "kimi-k3",
|
||||
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner",
|
||||
"qwen3", "qwq", "deepseek-r1", "deepseek-reasoner", "deepseek-v4",
|
||||
"minimax", "m2-reap", "gemma", "stepfun", "step-3", "step3",
|
||||
"magistral", "mistral-small", "mistral-medium",
|
||||
))
|
||||
|
||||
+2
-9
@@ -6,14 +6,11 @@ writable, and storage is local-first. Served by ``GET /api/ready`` and suitable
|
||||
for an orchestrator readiness probe (200 only when every critical check passes).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_readiness() -> Dict[str, object]:
|
||||
"""Run the readiness checks and return a JSON-serialisable report.
|
||||
@@ -36,10 +33,7 @@ def check_readiness() -> Dict[str, object]:
|
||||
conn.execute(sql_text("SELECT 1"))
|
||||
checks["database"] = {"ok": True}
|
||||
except Exception as e:
|
||||
# The raw driver error can carry the DB host/user/path; keep it in the
|
||||
# server log and give the client only the exception type.
|
||||
logger.warning("Readiness database check failed: %s", e)
|
||||
checks["database"] = {"ok": False, "error_type": type(e).__name__}
|
||||
checks["database"] = {"ok": False, "error": str(e)}
|
||||
|
||||
# Data directory present and writable — home must be able to hold its own data.
|
||||
try:
|
||||
@@ -50,8 +44,7 @@ def check_readiness() -> Dict[str, object]:
|
||||
os.remove(probe)
|
||||
checks["data_dir"] = {"ok": True, "path": DATA_DIR}
|
||||
except Exception as e:
|
||||
logger.warning("Readiness data_dir check failed: %s", e)
|
||||
checks["data_dir"] = {"ok": False, "error_type": type(e).__name__}
|
||||
checks["data_dir"] = {"ok": False, "error": str(e)}
|
||||
|
||||
# Local-first: storage stays on the home machine (informational, never fatal).
|
||||
local_first = (
|
||||
|
||||
@@ -47,6 +47,13 @@ DEFAULT_SETTINGS = {
|
||||
"image_gen_enabled": False,
|
||||
"image_model": "",
|
||||
"image_quality": "medium",
|
||||
# Local editor-agent/planner model (Quick Edit NL planner). When empty the
|
||||
# server auto-detects the local Qwen3.5-9B vision server on seneca and falls
|
||||
# back to the hosted chat model. See src/editor_agent_model.py.
|
||||
"editor_agent_base_url": "",
|
||||
"editor_agent_model": "",
|
||||
"editor_caption_base_url": "",
|
||||
"editor_caption_model": "",
|
||||
"vision_model": "",
|
||||
"vision_enabled": True,
|
||||
# Ordered fallback chain for the Vision model (image analysis, OCR, tagging).
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Process-wide shutdown signalling for graceful restarts.
|
||||
|
||||
Server-Sent-Events / streaming responses never complete on their own, so
|
||||
uvicorn's graceful shutdown would wait for an open browser stream up to the
|
||||
full ``timeout_graceful_shutdown`` window before exiting. A SIGTERM/SIGINT
|
||||
handler flips this flag and tells uvicorn to exit; every streaming generator
|
||||
checks the flag on each loop and ends promptly, so a restart completes in
|
||||
seconds while ordinary in-flight requests still finish.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_SHUTTING_DOWN = threading.Event()
|
||||
|
||||
|
||||
def is_shutting_down() -> bool:
|
||||
"""True once a shutdown (SIGTERM/SIGINT) has been requested."""
|
||||
return _SHUTTING_DOWN.is_set()
|
||||
|
||||
|
||||
def request_shutdown() -> None:
|
||||
"""Mark the process as shutting down (idempotent)."""
|
||||
if not _SHUTTING_DOWN.is_set():
|
||||
_SHUTTING_DOWN.set()
|
||||
logger.info("shutdown requested; streaming responses will end promptly")
|
||||
|
||||
|
||||
def reset() -> None:
|
||||
"""Clear the flag (for tests)."""
|
||||
_SHUTTING_DOWN.clear()
|
||||
+59
-22
@@ -1969,20 +1969,21 @@ class TaskScheduler:
|
||||
messages.append(datetime_context_msg)
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
# Recover the registered runtime URL and credentials for this chat route.
|
||||
# Resolve headers from the endpoint's API key
|
||||
headers = {}
|
||||
try:
|
||||
from core.database import SessionLocal
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url, build_headers, resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
from core.database import SessionLocal, ModelEndpoint
|
||||
from src.endpoint_resolver import normalize_base, build_headers, same_endpoint_base
|
||||
from src.auth_helpers import owner_filter
|
||||
db2 = SessionLocal()
|
||||
try:
|
||||
ep = resolve_owner_registered_endpoint(db2, endpoint_url, task.owner or None)
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
||||
endpoint_url = build_chat_url(base)
|
||||
headers = build_headers(api_key, base)
|
||||
ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None)
|
||||
eps = ep_q.all()
|
||||
for ep in eps:
|
||||
if same_endpoint_base(endpoint_url, ep.base_url):
|
||||
headers = build_headers(ep.api_key, normalize_base(ep.base_url))
|
||||
break
|
||||
finally:
|
||||
db2.close()
|
||||
except Exception:
|
||||
@@ -2169,23 +2170,26 @@ class TaskScheduler:
|
||||
endpoint_url, model = self._resolve_defaults(db, task.owner)
|
||||
if not endpoint_url or not model:
|
||||
raise RuntimeError("No model/endpoint configured for research")
|
||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||
# Record the resolved model for the run record (see _execute_task_locked).
|
||||
self._last_run_model = model
|
||||
|
||||
# Authorize the selected URL before normalization can collapse service paths.
|
||||
# Resolve headers
|
||||
try:
|
||||
from src.endpoint_resolver import (
|
||||
build_chat_url, build_headers, resolve_endpoint_runtime,
|
||||
resolve_owner_registered_endpoint,
|
||||
)
|
||||
from core.database import ModelEndpoint
|
||||
from src.endpoint_resolver import normalize_base, build_headers, same_endpoint_base
|
||||
from src.auth_helpers import owner_filter
|
||||
db2 = db
|
||||
if not headers_from_resolver:
|
||||
ep = resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)
|
||||
base, api_key = resolve_endpoint_runtime(ep, owner=task.owner or None)
|
||||
endpoint_url = build_chat_url(base)
|
||||
headers = build_headers(api_key, base)
|
||||
ep_q = db2.query(ModelEndpoint).filter(ModelEndpoint.is_enabled == True)
|
||||
ep_q = owner_filter(ep_q, ModelEndpoint, task.owner or None)
|
||||
eps = ep_q.all()
|
||||
for ep in eps:
|
||||
if same_endpoint_base(endpoint_url, ep.base_url):
|
||||
headers = build_headers(ep.api_key, normalize_base(ep.base_url))
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
endpoint_url = _normalize_chat_endpoint(endpoint_url)
|
||||
|
||||
max_tokens = int(get_setting("research_max_tokens", 8192))
|
||||
extraction_timeout = int(get_setting("research_extraction_timeout_seconds", 90) or 90)
|
||||
@@ -2501,6 +2505,36 @@ class TaskScheduler:
|
||||
ScheduledTask.task_type == "action",
|
||||
).all() if row[0]
|
||||
}
|
||||
# Built-in housekeeping rows created before the request-authority
|
||||
# seal carry a NULL snapshot, so every scheduled run failed with
|
||||
# "Scheduled action has no matching server request authority".
|
||||
# Replace them: drop the unsealed row and let the seed loop below
|
||||
# recreate an authorized copy (server-owned ingress), preserving the
|
||||
# user's enable/pause choice. We never grant authority to the
|
||||
# existing row, so a model-origin row can never gain a built-in
|
||||
# grant this way.
|
||||
replaced_actions = {}
|
||||
stale_rows = db.query(ScheduledTask).filter(
|
||||
ScheduledTask.owner == owner,
|
||||
ScheduledTask.task_type == "action",
|
||||
ScheduledTask.action.in_(list(HOUSEKEEPING_DEFAULTS.keys())),
|
||||
).all()
|
||||
sealed_actions = {r.action for r in stale_rows
|
||||
if (r.request_authority_json or "").strip()}
|
||||
removed_status = {}
|
||||
for stale in stale_rows:
|
||||
if (stale.request_authority_json or "").strip():
|
||||
continue
|
||||
removed_status.setdefault(stale.action, stale.status)
|
||||
db.delete(stale)
|
||||
for action, status in removed_status.items():
|
||||
if action in sealed_actions:
|
||||
# A sealed copy already exists; the NULL one was just a
|
||||
# stale duplicate. Keep the sealed row and let the seed
|
||||
# loop skip this action.
|
||||
continue
|
||||
replaced_actions[action] = status
|
||||
existing_actions.discard(action)
|
||||
renamed = []
|
||||
builtin_tasks = db.query(ScheduledTask).filter(
|
||||
ScheduledTask.owner == owner,
|
||||
@@ -2661,12 +2695,15 @@ class TaskScheduler:
|
||||
output_target=defs.get("output_target", "none"),
|
||||
notifications_enabled=False,
|
||||
)
|
||||
if action in replaced_actions and replaced_actions[action] in ("active", "paused"):
|
||||
task.status = replaced_actions[action]
|
||||
db.add(task)
|
||||
seeded.append(action)
|
||||
if seeded or renamed or removed_dupes or retired_count:
|
||||
if seeded or renamed or removed_dupes or retired_count or replaced_actions:
|
||||
logger.info(
|
||||
"Housekeeping defaults for %s: seeded=%s renamed=%s deduped=%s retired=%s",
|
||||
"Housekeeping defaults for %s: seeded=%s renamed=%s deduped=%s retired=%s authority_replaced=%s",
|
||||
owner, seeded, sorted(set(renamed)), sorted(set(removed_dupes)), retired_count,
|
||||
sorted(replaced_actions),
|
||||
)
|
||||
# Always commit — the orphan-run sweep above may have produced
|
||||
# pending deletes even when no defaults changed.
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import logging
|
||||
from enum import Enum
|
||||
from hashlib import sha256
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Keep the existing wire values so the current route and no-build frontend do
|
||||
@@ -21,126 +16,6 @@ DENY_APPROVAL_DECISION = "deny"
|
||||
# session history contains a matching, resolved chat-session approval.
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER = "_tool_approval_chat_session_granted"
|
||||
|
||||
# The server's proof that IT resolved this approval. More than one route
|
||||
# writes caller-supplied metadata into session history, so a client can write
|
||||
# the shape of a resolved card directly; only the server can produce this.
|
||||
CHAT_SESSION_APPROVAL_SIGNATURE_FIELD = "_server_grant"
|
||||
|
||||
|
||||
def _grant_key() -> bytes | None:
|
||||
"""Key material for grant signatures, or None when it is unavailable.
|
||||
|
||||
Reuses the persistent application key so a grant survives a restart the
|
||||
way the transcript holding it does.
|
||||
"""
|
||||
try:
|
||||
from src.secret_storage import _load_or_create_key
|
||||
|
||||
return _load_or_create_key()
|
||||
except Exception as exc:
|
||||
logger.warning("Tool approval grant key unavailable: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def sign_chat_session_grant(
|
||||
session_id: object,
|
||||
approval_id: object,
|
||||
decision: object,
|
||||
) -> str | None:
|
||||
"""Return the server's signature for one resolved chat-session grant."""
|
||||
|
||||
key = _grant_key()
|
||||
if key is None:
|
||||
return None
|
||||
payload = "\x00".join(
|
||||
(
|
||||
str(session_id or ""),
|
||||
str(approval_id or ""),
|
||||
str(decision or "").strip().lower(),
|
||||
)
|
||||
)
|
||||
return hmac.new(key, payload.encode("utf-8"), sha256).hexdigest()
|
||||
|
||||
|
||||
# Message-metadata keys the server writes and a caller never should. Both are
|
||||
# read back as authority: ``tool_events`` carries the approval cards, and the
|
||||
# context marker is projected onto a turn once a grant is found.
|
||||
_SERVER_OWNED_METADATA_KEYS = (
|
||||
"tool_events",
|
||||
CHAT_SESSION_APPROVAL_CONTEXT_MARKER,
|
||||
)
|
||||
|
||||
|
||||
def sanitize_client_message_metadata(metadata):
|
||||
"""Drop server-owned keys from a caller-supplied message metadata blob.
|
||||
|
||||
Routes that persist a message on the caller's behalf accept this blob
|
||||
verbatim, which lets a caller write the shape of a resolved approval into
|
||||
its own transcript. The grant check verifies a signature, so this is not
|
||||
the control that closes that path; it keeps the state out of the
|
||||
transcript in the first place. Anything else in the blob is left alone.
|
||||
"""
|
||||
if not isinstance(metadata, dict):
|
||||
return metadata
|
||||
if not any(key in metadata for key in _SERVER_OWNED_METADATA_KEYS):
|
||||
return metadata
|
||||
return {
|
||||
key: value
|
||||
for key, value in metadata.items()
|
||||
if key not in _SERVER_OWNED_METADATA_KEYS
|
||||
}
|
||||
|
||||
|
||||
def stamp_chat_session_grant(
|
||||
ask_user: dict,
|
||||
session_id: object,
|
||||
decision: object,
|
||||
) -> None:
|
||||
"""Record the server's grant on a card it has just resolved.
|
||||
|
||||
Call this only from the server-side resolve path. A decision that does not
|
||||
grant chat-session scope leaves no signature behind, so downgrading a
|
||||
``deny`` to an ``approve`` in the transcript does not carry a usable one.
|
||||
"""
|
||||
if not isinstance(ask_user, dict):
|
||||
return
|
||||
if str(decision or "").strip().lower() != CHAT_SESSION_APPROVAL_DECISION:
|
||||
ask_user.pop(CHAT_SESSION_APPROVAL_SIGNATURE_FIELD, None)
|
||||
return
|
||||
signature = sign_chat_session_grant(
|
||||
session_id,
|
||||
ask_user.get("approval_id"),
|
||||
CHAT_SESSION_APPROVAL_DECISION,
|
||||
)
|
||||
if signature:
|
||||
ask_user[CHAT_SESSION_APPROVAL_SIGNATURE_FIELD] = signature
|
||||
|
||||
|
||||
def verify_chat_session_grant(
|
||||
signature: object,
|
||||
session_id: object,
|
||||
approval_id: object,
|
||||
decision: object,
|
||||
) -> bool:
|
||||
"""Whether *signature* is this server's grant for that exact approval.
|
||||
|
||||
Fails CLOSED: an absent, malformed, or unverifiable signature is not a
|
||||
grant. Binding the session and approval ids into the payload means a
|
||||
signature lifted from one chat cannot be replayed into another.
|
||||
"""
|
||||
# compare_digest accepts only ASCII strings. Treat arbitrary persisted
|
||||
# metadata as untrusted and require the exact representation we sign.
|
||||
if (
|
||||
not isinstance(signature, str)
|
||||
or len(signature) != sha256().digest_size * 2
|
||||
or any(character not in "0123456789abcdef" for character in signature)
|
||||
):
|
||||
return False
|
||||
expected = sign_chat_session_grant(session_id, approval_id, decision)
|
||||
if expected is None:
|
||||
return False
|
||||
return hmac.compare_digest(signature, expected)
|
||||
|
||||
|
||||
class ToolApprovalScope(str, Enum):
|
||||
# Surfaces without a resumable chat (the skill tester, unattended audits)
|
||||
|
||||
+18
-76
@@ -17,7 +17,7 @@ from types import MappingProxyType
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
from src.tool_approval_scopes import CHAT_SESSION_APPROVAL_CONTEXT_MARKER
|
||||
from src.tool_security import BUILTIN_EMAIL_TOOLS, is_public_blocked_tool
|
||||
from src.tool_security import BUILTIN_EMAIL_TOOLS
|
||||
|
||||
|
||||
class ToolEffect(str, Enum):
|
||||
@@ -216,6 +216,20 @@ _register(
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"ai_edit_image"},
|
||||
ToolEffect.NETWORK_EGRESS,
|
||||
ToolEffect.WRITE_PRIVATE,
|
||||
result_integrity=ResultIntegrity.EXTERNAL_UNTRUSTED,
|
||||
)
|
||||
_register(
|
||||
{"get_editor_screenshot"},
|
||||
ToolEffect.READ_PRIVATE,
|
||||
)
|
||||
_register(
|
||||
{"plan_edit", "run_editor_command"},
|
||||
ToolEffect.UI_SIDE_EFFECT,
|
||||
)
|
||||
_register(
|
||||
{
|
||||
"archive_email",
|
||||
@@ -501,43 +515,6 @@ def capabilities_for_action(tool_name: Any, content: Any) -> ToolCapabilities:
|
||||
if not isinstance(tool_name, str):
|
||||
return base
|
||||
|
||||
if tool_name in {"web_fetch", "pdf_extract"}:
|
||||
payload = content
|
||||
raw = content.strip() if isinstance(content, str) else ""
|
||||
if isinstance(payload, str):
|
||||
try:
|
||||
payload = json.loads(raw) if raw.startswith("{") else {}
|
||||
except (TypeError, ValueError):
|
||||
payload = {}
|
||||
if not isinstance(payload, Mapping):
|
||||
payload = {}
|
||||
if tool_name == "web_fetch" and "urls" in payload:
|
||||
urls = payload["urls"]
|
||||
sources = []
|
||||
if isinstance(urls, list):
|
||||
for item in urls:
|
||||
source = item.get("url") if isinstance(item, Mapping) else item
|
||||
sources.append(str(source or "").strip())
|
||||
else:
|
||||
source = payload.get("url") or (payload.get("path") if tool_name == "pdf_extract" else "")
|
||||
sources = [str(source or "").strip() or raw.split("\n", 1)[0].strip()]
|
||||
# Match the native readers' local selectors without resolving or
|
||||
# opening files. A mixed batch retains its network effects as well.
|
||||
if tool_name == "web_fetch":
|
||||
local = [source.startswith("/workspace/") or source.lower().startswith("file:///workspace/")
|
||||
for source in sources]
|
||||
else:
|
||||
local = [os.path.isabs(source) or source.lower().startswith("file://") for source in sources]
|
||||
if any(local):
|
||||
effects = {ToolEffect.READ_WORKSPACE}
|
||||
if not all(local):
|
||||
effects.update(base.effects)
|
||||
return ToolCapabilities(
|
||||
frozenset(effects),
|
||||
ResultIntegrity.WORKSPACE_UNTRUSTED if all(local) else base.result_integrity,
|
||||
known=base.known,
|
||||
)
|
||||
|
||||
if tool_name == "extract_text":
|
||||
payload = content
|
||||
if isinstance(payload, str):
|
||||
@@ -678,13 +655,9 @@ POST_EXTERNAL_BLOCKED_EFFECTS = frozenset(
|
||||
)
|
||||
|
||||
|
||||
# On by default: until agent processes run without network and side-effecting
|
||||
# non-process tools have their own exact-approval boundary, this gate is the
|
||||
# only check between injected external content and those tools. Set it to a
|
||||
# falsy value to opt out.
|
||||
TOOL_APPROVAL_GATE_ENABLED = (
|
||||
str(os.getenv("ODYSSEUS_TOOL_APPROVAL_GATE", "1")).strip().lower()
|
||||
not in {"0", "false", "no", "off"}
|
||||
str(os.getenv("ODYSSEUS_TOOL_APPROVAL_GATE", "0")).strip().lower()
|
||||
in {"1", "true", "yes", "on"}
|
||||
)
|
||||
|
||||
|
||||
@@ -751,18 +724,6 @@ def messages_contain_external_untrusted_context(messages: Iterable[dict]) -> boo
|
||||
return False
|
||||
|
||||
|
||||
def delegated_tool_is_blocked(tool_name: Any, content: Any = None) -> bool:
|
||||
"""Filter tool names for discovery and concrete workspace reads at dispatch."""
|
||||
# The bridge exposes these spellings for the same filesystem surfaces.
|
||||
if isinstance(tool_name, str):
|
||||
tool_name = tool_name.strip()
|
||||
tool_name = {"list_dir": "ls", "find_files": "glob"}.get(tool_name, tool_name)
|
||||
return is_public_blocked_tool(tool_name) or (
|
||||
content is not None
|
||||
and ToolEffect.READ_WORKSPACE in capabilities_for_action(tool_name, content).effects
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolRunSecurityContext:
|
||||
"""Server-owned integrity state for one agent run."""
|
||||
@@ -779,19 +740,11 @@ class ToolRunSecurityContext:
|
||||
# The bypass affects only this automatic gate; current tool policy, ownership,
|
||||
# workspace confinement, and execution/sandbox restrictions still apply.
|
||||
approval_gate_bypassed: bool = False
|
||||
# Driven by a bearer API token, not a person at a browser. Privileged
|
||||
# tools are refused outright and no approval can lift that.
|
||||
delegated_credential: bool = False
|
||||
|
||||
def observe_messages(self, messages: Iterable[dict]) -> None:
|
||||
"""Apply server-owned chat scope and promote untrusted prompt context."""
|
||||
message_list = list(messages or ())
|
||||
if self.delegated_credential:
|
||||
# A delegated run has no human to grant chat-session scope, so a
|
||||
# grant sitting in this chat's history (left by the owner's own
|
||||
# browser) must not be picked up by a token driving the same chat.
|
||||
self.approval_gate_bypassed = False
|
||||
elif any(
|
||||
if any(
|
||||
isinstance(message, dict)
|
||||
and isinstance(message.get("metadata"), dict)
|
||||
and message["metadata"].get(
|
||||
@@ -816,17 +769,6 @@ class ToolRunSecurityContext:
|
||||
self.external_untrusted_context_seen = True
|
||||
|
||||
def decision_for(self, tool_name: Any, content: Any = None) -> ToolGateDecision:
|
||||
# Checked before the bypasses below, because neither may lift it, and
|
||||
# kept independent of external_untrusted_context_seen so it holds on a
|
||||
# run where that gate never arms and raises no prompt to bypass.
|
||||
if self.delegated_credential and delegated_tool_is_blocked(tool_name, content):
|
||||
return ToolGateDecision(
|
||||
False,
|
||||
(
|
||||
f"Tool '{tool_name}' is not available to API-token callers. "
|
||||
"It requires an interactive session."
|
||||
),
|
||||
)
|
||||
if not TOOL_APPROVAL_GATE_ENABLED:
|
||||
return ToolGateDecision(True)
|
||||
if self.approval_gate_bypassed:
|
||||
|
||||
+39
-304
@@ -15,7 +15,6 @@ import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import stat
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
@@ -37,7 +36,6 @@ from src.tool_policy import ToolPolicy
|
||||
from src.client_tool_contract import TUI_ROUTED_BRIDGE_TOOL_NAMES
|
||||
from src.constants import (
|
||||
DATA_DIR,
|
||||
AGENT_WORKSPACE_DIR,
|
||||
MAX_DIFF_LINES,
|
||||
MAX_OUTPUT_CHARS,
|
||||
MAX_READ_CHARS,
|
||||
@@ -71,11 +69,11 @@ _MISSING_TOOL_SECURITY_CONTEXT = _MissingToolSecurityContext()
|
||||
NO_TOOL_SECURITY_CONTEXT = _NoToolSecurityContext()
|
||||
|
||||
# Persistent working directory for agent subprocesses.
|
||||
# Resolves to <repo_root>/data/agent_workspace, inside the bind-mounted volume
|
||||
# in Docker (/app/data), so files survive a rebuild as before. The subdirectory
|
||||
# rather than data/ itself keeps agent scratch files and dotfiles out of the
|
||||
# directory holding the session store and the auth database.
|
||||
_AGENT_WORKDIR = AGENT_WORKSPACE_DIR
|
||||
# Resolves to <repo_root>/data, which is the bind-mounted volume in Docker
|
||||
# (/app/data) and the local data directory for manual installs.
|
||||
# Using this as cwd and HOME prevents the agent from silently creating files
|
||||
# in ephemeral container layers that are lost on the next rebuild.
|
||||
_AGENT_WORKDIR = DATA_DIR
|
||||
|
||||
|
||||
ExecutionBridgeHandler = Callable[
|
||||
@@ -135,12 +133,6 @@ def get_active_execution_bridge() -> AgentExecutionBridge | None:
|
||||
return _active_execution_bridge.get()
|
||||
|
||||
|
||||
def _tui_host_bridge_endpoint_url(url: str, path: str) -> str:
|
||||
"""Replace a validated bridge URL's path without changing its authority."""
|
||||
from src.agent_tools.subprocess_tools import host_shell_bridge_endpoint_url
|
||||
return host_shell_bridge_endpoint_url(url, path)
|
||||
|
||||
|
||||
def _tui_host_bridge_patch_url(
|
||||
client_runtime_context: Optional[Dict[str, Any]],
|
||||
) -> tuple[str, str] | None:
|
||||
@@ -161,7 +153,11 @@ def _tui_host_bridge_patch_url(
|
||||
from src.agent_tools.subprocess_tools import is_host_shell_bridge_url_allowed
|
||||
if not is_host_shell_bridge_url_allowed(url):
|
||||
return None
|
||||
return _tui_host_bridge_endpoint_url(url, "/patch"), token
|
||||
if url.endswith("/run"):
|
||||
url = url[:-4] + "/patch"
|
||||
elif not url.endswith("/patch"):
|
||||
url += "/patch"
|
||||
return url, token
|
||||
|
||||
|
||||
async def _apply_patch_via_tui_host_bridge(
|
||||
@@ -177,7 +173,7 @@ async def _apply_patch_via_tui_host_bridge(
|
||||
import httpx
|
||||
|
||||
timeout = httpx.Timeout(125.0, connect=5.0, write=10.0, pool=5.0)
|
||||
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers={"x-odysseus-tui-bridge-token": token},
|
||||
@@ -237,7 +233,7 @@ async def _bridge_post(bridge: Dict, path: str, payload: Dict, *, timeout_s: flo
|
||||
"error": f"{err_prefix}: invalid TUI host bridge",
|
||||
"exit_code": 1,
|
||||
}
|
||||
target = _tui_host_bridge_endpoint_url(url, path)
|
||||
base = url.rsplit("/", 1)[0] if url.endswith(("/run", "/read", "/write")) else url.rstrip("/")
|
||||
try:
|
||||
import httpx
|
||||
timeout = httpx.Timeout(
|
||||
@@ -246,9 +242,9 @@ async def _bridge_post(bridge: Dict, path: str, payload: Dict, *, timeout_s: flo
|
||||
write=10.0,
|
||||
pool=5.0,
|
||||
)
|
||||
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.post(
|
||||
target,
|
||||
f"{base}{path}",
|
||||
headers={"x-odysseus-tui-bridge-token": token},
|
||||
json=payload,
|
||||
)
|
||||
@@ -304,13 +300,13 @@ async def _cancel_bridge_request(bridge: Dict, request_id: str) -> None:
|
||||
from src.agent_tools.subprocess_tools import is_host_shell_bridge_url_allowed
|
||||
if not token or not is_host_shell_bridge_url_allowed(url):
|
||||
return
|
||||
target = _tui_host_bridge_endpoint_url(url, "/cancel")
|
||||
base = url.rsplit("/", 1)[0]
|
||||
try:
|
||||
import httpx
|
||||
timeout = httpx.Timeout(5.0, connect=2.0, write=2.0, pool=2.0)
|
||||
async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
await client.post(
|
||||
target,
|
||||
f"{base}/cancel",
|
||||
headers={"x-odysseus-tui-bridge-token": token},
|
||||
json={"request_id": request_id},
|
||||
)
|
||||
@@ -733,7 +729,7 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s
|
||||
path = path_value.strip() if isinstance(path_value, str) else ""
|
||||
if "\n" in path or "\r" in path:
|
||||
path = ""
|
||||
body_value = args.get("content")
|
||||
body_value = args.get("content", "")
|
||||
if not isinstance(body_value, str):
|
||||
return "write_file: invalid arguments", {
|
||||
"error": "write_file: content must be a string",
|
||||
@@ -743,7 +739,7 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s
|
||||
else:
|
||||
path, _, body = content.partition("\n")
|
||||
path = path.strip()
|
||||
if not path or path.endswith(("/", "\\")):
|
||||
if not path:
|
||||
return "write_file: invalid arguments", {
|
||||
"error": "write_file: path is required",
|
||||
"exit_code": 1,
|
||||
@@ -751,17 +747,6 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s
|
||||
rejected = _text_write_to_binary_artifact_result(content)
|
||||
if rejected is not None:
|
||||
return rejected
|
||||
from src.agent_tools.filesystem_tools import _parse_write_intent, _unwrap_fenced_source_body
|
||||
try:
|
||||
_, original_body, section, explicit_clear = _parse_write_intent(content)
|
||||
except ValueError as error:
|
||||
return "write_file: invalid arguments", {"error": str(error), "exit_code": 1}
|
||||
body = _unwrap_fenced_source_body(body, path)
|
||||
if not section or (not body.strip() and not explicit_clear):
|
||||
return "write_file: ambiguous content", {
|
||||
"error": "write_file: bridge cannot safely enforce implicit empty content; use explicit JSON string content",
|
||||
"exit_code": 1,
|
||||
}
|
||||
return f"write_file: {path[:80]}", await _bridge_post(
|
||||
bridge,
|
||||
"/write",
|
||||
@@ -788,15 +773,10 @@ async def _route_tool_via_bridge(tool: str, content: str, session_id: Optional[s
|
||||
# 1. Sensitive-subpath deny list — checked FIRST. Blocks .ssh,
|
||||
# .gnupg, shell rc files, token/env files even if the root above
|
||||
# them is on the allowlist.
|
||||
# 2. Application-state deny (_is_app_state_path) - DATA_DIR holds the
|
||||
# session store, auth database, app key and settings, so only
|
||||
# _agent_readable_data_subdirs() is readable inside it.
|
||||
# 3. Allowlist - only the directories the agent legitimately needs
|
||||
# (its data/ workspace, user content, system tmp). $HOME is NOT on
|
||||
# the default list.
|
||||
# 4. Opt-in extra roots - admin can add broader roots via the
|
||||
# "tool_path_extra_roots" setting. These cannot re-open DATA_DIR;
|
||||
# rule 2 is independent of which root a path arrived through.
|
||||
# 2. Allowlist — only the directories the agent legitimately needs
|
||||
# (project data/, system tmp). $HOME is NOT on the default list.
|
||||
# 3. Opt-in extra roots — admin can add broader roots via the
|
||||
# "tool_path_extra_roots" setting (list of path strings).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Compatibility exports: the same deny predicate protects tool access and
|
||||
@@ -807,187 +787,6 @@ from src.agent_runtime.path_policy import (
|
||||
)
|
||||
|
||||
|
||||
def _path_within(resolved: str, root: str) -> bool:
|
||||
"""True when *resolved* is *root* itself or sits underneath it.
|
||||
|
||||
Use the platform's path-case rules. This helper participates in allow
|
||||
decisions, so unconditional case-folding would let a distinct ``/DATA``
|
||||
tree masquerade as a descendant of ``/data`` on case-sensitive systems.
|
||||
"""
|
||||
resolved, root = os.path.normcase(resolved), os.path.normcase(root)
|
||||
if resolved == root:
|
||||
return True
|
||||
try:
|
||||
if os.path.commonpath([resolved, root]) == root:
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
# normcase is intentionally conservative about assumptions (notably on
|
||||
# POSIX), so consult the filesystem when paths exist. This recognizes a
|
||||
# case alias on a case-insensitive volume without treating distinct
|
||||
# case-sensitive paths as the same allow root.
|
||||
if os.path.exists(root):
|
||||
candidate = resolved
|
||||
while True:
|
||||
try:
|
||||
if os.path.exists(candidate) and os.path.samefile(candidate, root):
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
parent = os.path.dirname(candidate)
|
||||
if parent == candidate:
|
||||
break
|
||||
candidate = parent
|
||||
return False
|
||||
|
||||
|
||||
def _path_within_conservative(resolved: str, root: str) -> bool:
|
||||
"""Containment for deny decisions, folding case to fail closed."""
|
||||
resolved, root = resolved.casefold(), root.casefold()
|
||||
if resolved == root:
|
||||
return True
|
||||
try:
|
||||
return os.path.commonpath([resolved, root]) == root
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _agent_readable_data_subdirs() -> tuple[str, ...]:
|
||||
"""The only parts of DATA_DIR the agent's file tools may reach.
|
||||
|
||||
The agent's own scratch folder, plus the directories of user content whose
|
||||
paths the application itself gives to the model, which it would then be
|
||||
unable to open. These normally live under DATA_DIR; the documented mail
|
||||
attachment override may instead name a disjoint external directory:
|
||||
|
||||
UPLOAD_DIR the chat upload manifest renders "path=<p>" and
|
||||
says to read it with read_file (agent_loop.py)
|
||||
MAIL_ATTACHMENTS_DIR download_attachment returns the path and its own
|
||||
description tells the model to read it
|
||||
PERSONAL_DIR GET /api/personal returns a path per file and is
|
||||
reachable through the app_api tool; RUNBOOK_DIR
|
||||
nests under it
|
||||
PERSONAL_UPLOADS_DIR indexed as a personal-docs directory, which
|
||||
manage_rag lists as an absolute path
|
||||
|
||||
Order matters: the first entry is roots[0], which _resolve_search_root uses
|
||||
when grep/glob/ls are called with no path.
|
||||
"""
|
||||
from src.constants import (
|
||||
DATA_DIR,
|
||||
MAIL_ATTACHMENTS_DIR,
|
||||
PERSONAL_DIR,
|
||||
PERSONAL_UPLOADS_DIR,
|
||||
UPLOAD_DIR,
|
||||
)
|
||||
configured = (
|
||||
(AGENT_WORKSPACE_DIR, "agent_workspace", False),
|
||||
(UPLOAD_DIR, "uploads", False),
|
||||
# This has a documented environment override and may legitimately
|
||||
# live outside DATA_DIR, but it must never equal/contain DATA_DIR.
|
||||
(MAIL_ATTACHMENTS_DIR, "mail-attachments", True),
|
||||
(PERSONAL_DIR, "personal_docs", False),
|
||||
(PERSONAL_UPLOADS_DIR, "personal_uploads", False),
|
||||
)
|
||||
configured_data_dir = os.path.abspath(os.path.expanduser(str(DATA_DIR)))
|
||||
data_dir = os.path.realpath(configured_data_dir)
|
||||
safe: list[str] = []
|
||||
for raw, internal_name, external_ok in configured:
|
||||
value = str(raw or "").strip()
|
||||
# These paths are security-policy roots, not ordinary allowlist
|
||||
# entries. Internal roles may inherit a relative DATA_DIR, but must
|
||||
# still resolve to their exact canonical child below. External mail
|
||||
# overrides require an absolute, disjoint directory.
|
||||
if not value:
|
||||
continue
|
||||
expanded = os.path.abspath(os.path.expanduser(value))
|
||||
# A policy root must not acquire an exemption by redirecting its final
|
||||
# path component to protected state or to an unrelated external tree.
|
||||
if os.path.islink(expanded):
|
||||
continue
|
||||
resolved = os.path.realpath(expanded)
|
||||
if os.path.exists(resolved) and not os.path.isdir(resolved):
|
||||
continue
|
||||
expected_internal = os.path.join(data_dir, internal_name)
|
||||
expected_configured = os.path.join(configured_data_dir, internal_name)
|
||||
inside_data = (
|
||||
os.path.normcase(expanded)
|
||||
in {
|
||||
os.path.normcase(expected_configured),
|
||||
os.path.normcase(expected_internal),
|
||||
}
|
||||
and resolved == expected_internal
|
||||
)
|
||||
external_safe = (
|
||||
external_ok
|
||||
and os.path.isabs(os.path.expanduser(value))
|
||||
and resolved != data_dir
|
||||
and os.path.dirname(resolved) != resolved
|
||||
and not _path_within(data_dir, resolved)
|
||||
and not _path_within(resolved, data_dir)
|
||||
)
|
||||
if not (inside_data or external_safe) or _is_sensitive_path(resolved):
|
||||
continue
|
||||
safe.append(resolved)
|
||||
return tuple(safe)
|
||||
|
||||
|
||||
def _is_app_state_path(resolved: str) -> bool:
|
||||
"""True for anything under DATA_DIR that is not agent-readable.
|
||||
|
||||
DATA_DIR holds the session store, the auth database, the app encryption key
|
||||
and the settings file. A model-supplied path must not reach those through
|
||||
any root, so this is checked in both resolvers rather than expressed as an
|
||||
absence from the allowlist: a workspace bound at or above the data
|
||||
directory, or an opt-in tool_path_extra_roots entry covering it, would
|
||||
otherwise put them back in reach.
|
||||
|
||||
A containment rule rather than a filename deny list, so state files added
|
||||
later are covered without anyone remembering to list them. The composed
|
||||
sensitive-name policy also denies settings.json and app.db in workspaces.
|
||||
"""
|
||||
from src.constants import DATA_DIR
|
||||
if not _path_within_conservative(resolved, os.path.realpath(DATA_DIR)):
|
||||
return False
|
||||
return not any(
|
||||
_path_within(resolved, d)
|
||||
for d in _agent_readable_data_subdirs()
|
||||
)
|
||||
|
||||
|
||||
def _is_hardlinked_regular_file(resolved: str) -> bool:
|
||||
"""Reject inode aliases that can smuggle DATA_DIR state into an allow root."""
|
||||
try:
|
||||
target = os.stat(resolved, follow_symlinks=False)
|
||||
except OSError:
|
||||
return False
|
||||
return stat.S_ISREG(target.st_mode) and getattr(target, "st_nlink", 1) > 1
|
||||
|
||||
|
||||
def _is_denied_tool_path(resolved: str) -> bool:
|
||||
"""Apply every path deny to a canonical traversal result."""
|
||||
from src.agent_runtime.resources import _control_plane_path
|
||||
return (
|
||||
_is_sensitive_path(resolved)
|
||||
or _is_app_state_path(resolved)
|
||||
or _is_hardlinked_regular_file(resolved)
|
||||
or _control_plane_path(resolved)
|
||||
)
|
||||
|
||||
|
||||
def _can_traverse_tool_path(resolved: str) -> bool:
|
||||
"""Allow walking a denied state parent only to reach safe carve-outs."""
|
||||
if _is_sensitive_path(resolved):
|
||||
return False
|
||||
if not _is_app_state_path(resolved):
|
||||
from src.agent_runtime.resources import _control_plane_path
|
||||
return not _control_plane_path(resolved)
|
||||
return any(
|
||||
_path_within(readable, resolved)
|
||||
for readable in _agent_readable_data_subdirs()
|
||||
)
|
||||
|
||||
|
||||
def _tool_path_roots() -> list[str]:
|
||||
"""Return the list of directory roots that read_file / write_file
|
||||
may touch. Default: project data/ + system temp dirs. Extra roots
|
||||
@@ -995,9 +794,9 @@ def _tool_path_roots() -> list[str]:
|
||||
"""
|
||||
roots: list[str] = []
|
||||
|
||||
# The agent's workspace plus the user-content directories inside data/.
|
||||
# The rest of DATA_DIR is denied by _is_app_state_path.
|
||||
roots.extend(_agent_readable_data_subdirs())
|
||||
# Project data directory — the agent's primary workspace.
|
||||
from src.constants import DATA_DIR
|
||||
roots.append(DATA_DIR)
|
||||
|
||||
# /tmp (and its macOS realpath /private/tmp).
|
||||
roots.append("/tmp")
|
||||
@@ -1054,10 +853,7 @@ def _resolve_tool_path(raw_path: str) -> str:
|
||||
"""
|
||||
resource_operation = active_resource_operation()
|
||||
if resource_operation is not None:
|
||||
resolved = resource_operation.resolve_path(raw_path)
|
||||
if _is_denied_tool_path(resolved):
|
||||
raise ValueError("bound path is sensitive, application state or a hard-linked file")
|
||||
return resolved
|
||||
return resource_operation.resolve_path(raw_path)
|
||||
ws = get_active_workspace()
|
||||
if ws:
|
||||
return _resolve_tool_path_in_workspace(ws, raw_path)
|
||||
@@ -1066,20 +862,11 @@ def _resolve_tool_path(raw_path: str) -> str:
|
||||
expanded = os.path.expanduser(str(raw_path).strip())
|
||||
resolved = os.path.realpath(expanded)
|
||||
|
||||
if _is_app_state_path(resolved):
|
||||
raise ValueError(
|
||||
f"path '{raw_path}' is inside the application state directory"
|
||||
)
|
||||
if _is_sensitive_path(resolved):
|
||||
raise ValueError(
|
||||
f"path '{raw_path}' is inside a sensitive directory "
|
||||
f"(e.g. .ssh, .gnupg) or matches a sensitive filename"
|
||||
)
|
||||
if _is_hardlinked_regular_file(resolved):
|
||||
raise ValueError(f"path '{raw_path}' is a hard-linked file")
|
||||
|
||||
if _is_denied_tool_path(resolved):
|
||||
raise ValueError("path addresses sensitive execution control-plane state")
|
||||
|
||||
for root in _tool_path_roots():
|
||||
if is_inside(root, resolved):
|
||||
@@ -1116,19 +903,11 @@ def _resolve_tool_path_in_workspace(workspace: str, raw_path: str) -> str:
|
||||
# Confinement says "inside the root"; the deny list says "allowed". They
|
||||
# are separate questions and this one stays here, with the policy that
|
||||
# owns it.
|
||||
if _is_app_state_path(resolved):
|
||||
raise ValueError(
|
||||
f"path '{raw_path}' is inside the application state directory"
|
||||
)
|
||||
if _is_sensitive_path(resolved):
|
||||
raise ValueError(
|
||||
f"path '{raw_path}' is inside a sensitive directory "
|
||||
f"(e.g. .ssh, .gnupg) or matches a sensitive filename"
|
||||
)
|
||||
if _is_hardlinked_regular_file(resolved):
|
||||
raise ValueError(f"path '{raw_path}' is a hard-linked file")
|
||||
if _is_denied_tool_path(resolved):
|
||||
raise ValueError("path addresses sensitive execution control-plane state")
|
||||
return resolved
|
||||
|
||||
|
||||
@@ -1187,10 +966,6 @@ def vet_workspace(raw: str) -> Optional[str]:
|
||||
resolved = os.path.realpath(os.path.expanduser(raw))
|
||||
if not os.path.isdir(resolved) or _is_sensitive_path(resolved):
|
||||
return None
|
||||
# Refuse the bind rather than binding a workspace where every subsequent
|
||||
# tool call would fail on the same deny list.
|
||||
if _is_denied_tool_path(resolved):
|
||||
return None
|
||||
# Reject filesystem roots: binding / (or a Windows drive/UNC root) as the
|
||||
# workspace would make every absolute path "inside" it, collapsing the
|
||||
# confinement into host-wide file access. A root is its own dirname, which
|
||||
@@ -1202,25 +977,11 @@ def vet_workspace(raw: str) -> Optional[str]:
|
||||
|
||||
def agent_cwd() -> str:
|
||||
"""Working directory for agent subprocesses (bash/python/background jobs):
|
||||
a validated sealed launch root, active workspace, or agent workspace."""
|
||||
the active workspace when set, else the persistent data dir."""
|
||||
from src.agent_runtime.process_resources import active_process_operation
|
||||
bound = active_process_operation()
|
||||
if bound is not None and bound.launch is not None:
|
||||
bound.launch.scope.validate()
|
||||
root = bound.launch.scope.root.path
|
||||
if vet_workspace(root) != root:
|
||||
raise RuntimeError("sealed process root is not a safe real directory")
|
||||
return root
|
||||
workspace = get_active_workspace()
|
||||
if workspace:
|
||||
vetted = vet_workspace(workspace)
|
||||
if vetted is None:
|
||||
raise RuntimeError("active workspace is not a safe real directory")
|
||||
return vetted
|
||||
resolved = os.path.realpath(_AGENT_WORKDIR)
|
||||
if resolved not in _agent_readable_data_subdirs() or vet_workspace(resolved) != resolved:
|
||||
raise RuntimeError("agent workspace is not a safe real directory")
|
||||
return resolved
|
||||
return (bound.launch.scope.root.path if bound is not None and bound.launch is not None
|
||||
else get_active_workspace() or _AGENT_WORKDIR)
|
||||
|
||||
|
||||
def get_mcp_manager():
|
||||
@@ -1235,28 +996,19 @@ def _resolve_search_root(raw_path: str) -> str:
|
||||
|
||||
With a workspace active, the workspace folder is the root and a supplied
|
||||
path is confined inside it. Otherwise an empty path defaults to the agent's
|
||||
primary root (its workspace under the project data dir) and a supplied path
|
||||
is confined by the global allowlist + sensitive-file policy.
|
||||
primary root (project data dir) and a supplied path is confined by the
|
||||
global allowlist + sensitive-file policy.
|
||||
"""
|
||||
resource_operation = active_resource_operation()
|
||||
if resource_operation is not None:
|
||||
resolved = resource_operation.resolve_path(raw_path, search=True)
|
||||
if _is_denied_tool_path(resolved):
|
||||
raise ValueError("bound search root is sensitive, application state or a hard-linked file")
|
||||
return resolved
|
||||
return resource_operation.resolve_path(raw_path, search=True)
|
||||
raw = (raw_path or "").strip()
|
||||
ws = get_active_workspace()
|
||||
if ws:
|
||||
# Resolve the empty case as the workspace path rather than returning
|
||||
# it directly: returned unchecked it skipped both deny lists, so a
|
||||
# bare ls listed whatever the workspace was bound to.
|
||||
return _resolve_tool_path_in_workspace(ws, raw or ws)
|
||||
return os.path.realpath(ws) if not raw else _resolve_tool_path_in_workspace(ws, raw)
|
||||
if not raw:
|
||||
roots = _tool_path_roots()
|
||||
default_root = os.path.realpath(AGENT_WORKSPACE_DIR)
|
||||
if default_root in roots and not _is_denied_tool_path(default_root):
|
||||
return default_root
|
||||
raise ValueError("default agent workspace is not a safe readable data subdirectory")
|
||||
return roots[0] if roots else os.path.realpath(".")
|
||||
return _resolve_tool_path(raw)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -1647,14 +1399,6 @@ async def execute_tool_block(
|
||||
"NO_TOOL_SECURITY_CONTEXT"
|
||||
)
|
||||
|
||||
# Credential denial precedes authority, exact approval claim and bridges.
|
||||
if (isinstance(security_context, ToolRunSecurityContext)
|
||||
and security_context.delegated_credential):
|
||||
from src.tool_capabilities import delegated_tool_is_blocked
|
||||
if delegated_tool_is_blocked(getattr(block, "tool_type", None), getattr(block, "content", None)):
|
||||
decision = security_context.decision_for(block.tool_type, block.content)
|
||||
return blocked_tool_result(block.tool_type, decision.reason)
|
||||
|
||||
authority = active_request_authority() if request_authority is MISSING_AUTHORITY else request_authority
|
||||
parent = active_request_authority()
|
||||
if isinstance(authority, RequestAuthority) and parent is not None and authority is not parent:
|
||||
@@ -2078,17 +1822,6 @@ async def _execute_tool_block_impl(
|
||||
|
||||
mark_authorized()
|
||||
if bridge_owns_tool:
|
||||
if tool == "write_file":
|
||||
from src.agent_tools.filesystem_tools import _parse_write_intent, _unwrap_fenced_source_body
|
||||
try:
|
||||
path, body, section, explicit_clear = _parse_write_intent(content)
|
||||
if not path or path.endswith(("/", "\\")) or "\n" in path or "\r" in path:
|
||||
raise ValueError("write_file: valid file path required")
|
||||
body = _unwrap_fenced_source_body(body, path)
|
||||
if not section or (not body.strip() and not explicit_clear):
|
||||
raise ValueError("write_file: bridge cannot safely enforce implicit empty content; use explicit JSON string content")
|
||||
except ValueError as error:
|
||||
return "write_file: invalid arguments", {"error": str(error), "exit_code": 1}
|
||||
try:
|
||||
return await dispatched(execution_bridge.route_tool(
|
||||
tool,
|
||||
@@ -2160,7 +1893,8 @@ async def _execute_tool_block_impl(
|
||||
if tool == "generate_image":
|
||||
from src.ai_interaction import do_generate_image
|
||||
desc = "generate_image"
|
||||
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner))
|
||||
image_privacy = (client_runtime_context or {}).get('web_ui_state', {}).get('incognito') is True
|
||||
result = await dispatched(do_generate_image(content, session_id=session_id, owner=owner, **({'incognito': True} if image_privacy else {})))
|
||||
elif (tool in NATIVE_FILESYSTEM_TOOLS
|
||||
and (active_resource_operation() is not None or tool != "apply_patch"
|
||||
or not _tui_host_bridge_patch_url(client_runtime_context))):
|
||||
@@ -2320,7 +2054,8 @@ async def _execute_tool_block_impl(
|
||||
result = await dispatched(do_list_cookbook_servers(content, owner=owner))
|
||||
elif tool == "edit_image":
|
||||
desc = "edit_image"
|
||||
result = await dispatched(do_edit_image(content, owner=owner))
|
||||
image_privacy = (client_runtime_context or {}).get('web_ui_state', {}).get('incognito') is True
|
||||
result = await dispatched(do_edit_image(content, owner=owner, **({'incognito': True} if image_privacy else {})))
|
||||
elif tool == "edit_file":
|
||||
result = await dispatched(_direct_fallback(tool, content)) or {"error": "edit failed", "exit_code": 1}
|
||||
desc = result.get("output") or result.get("error") or "edit_file"
|
||||
|
||||
+6
-2
@@ -111,7 +111,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"get_weather": "Get current weather and a three-day forecast for a city or place from Open-Meteo without an API key. Use for weather lookups before web_search.",
|
||||
"web_fetch": "Fetch and read the text content of a specific URL/website the user names (e.g. 'check example.com', 'open this link'). Use when you have a concrete URL; for open-ended lookups use web_search instead.",
|
||||
"pdf_extract": "Extract focused, source-attributed passages and exact table values from an online PDF or task-local /workspace/*.pdf. Use for arXiv papers, reports, manuals, PDF tables, evaluation metrics, and multi-document PDF extraction. Prefer this over Python requests, curl, downloading, pdftotext, or guessing. Include target model names, metrics, and table headings in query.",
|
||||
"youtube_tool": "Read YouTube-specific data without fighting the JS page: video comments, transcripts, metadata, or latest video from a channel. Use for YouTube comments/transcript/channel latest-video tasks.",
|
||||
"youtube_tool": "Read YouTube-specific data without fighting the JS page: video comments, transcripts, metadata, or latest video from a channel. YouTube ONLY — accepts only youtube.com or youtu.be links (video/shorts URL or 11-char video_id, or a channel /@handle/videos URL); for any other website use web_fetch or web_search instead.",
|
||||
"private_browser": "Trusted metadata for an existing server-registered browser session only. Page/document reads and interactions are unavailable because the configured producer cannot guarantee exact target binding. No model batch or raw browser commands. Use web_search or web_fetch for supported web access.",
|
||||
"inspect_media": "Inspect local workspace images, SVGs, videos, and PDF pages with the current multimodal model. Samples bounded timestamped video frames uniformly, at scene cuts, or from temporally diverse motion peaks; renders SVG to PNG; exports stills or clips; concatenates ranges; changes clip speed while preserving audio pitch; and renders query-relevant PDF pages. Prefer these native operations over raw ffmpeg. Increase max_dimension only for small visual details; saved exports keep source quality.",
|
||||
"extract_text": "Extract exact visible text, confidence, and pixel centers from a local workspace image with Odysseus local OCR. Use for screenshots, scans, labels, numbers, receipts, and text-location tasks; use inspect_media for general visual understanding.",
|
||||
@@ -129,7 +129,7 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"edit_document": "Preferred tool for editing an existing document — targeted find-and-replace. Use for any small change: add a function, fix a bug, tweak a section, rename things.",
|
||||
"update_document": "Replace the entire active document content. ONLY for full rewrites (>50% changed). Do not use for small edits — use edit_document instead.",
|
||||
"suggest_document": "Suggest changes to the active document with explanations. For code review, proofreading, feedback requests.",
|
||||
"generate_image": "Generate an AI image from a text prompt. Specify model, size, and quality. Art, illustrations, photos.",
|
||||
"generate_image": "Generate a NEW AI image from a text prompt (add content not already in the photo, e.g. a moon). Specify model, size, and quality. Art, illustrations, photos. Not for tone/filter edits.",
|
||||
"chat_with_model": "Send a message to a different AI model. Compare responses, get specialized help, delegate tasks.",
|
||||
"ask_teacher": "Ask a more capable model for help with a difficult problem. Escalate complex tasks.",
|
||||
"pipeline": "Run a multi-step AI pipeline with multiple models. Chain tasks together in sequence.",
|
||||
@@ -191,6 +191,10 @@ BUILTIN_TOOL_DESCRIPTIONS: Dict[str, str] = {
|
||||
"list_cookbook_servers": "List the cookbook's configured servers (remote GPU boxes + local) and which is the current default. Use this BEFORE download_model/serve_model when the user didn't name a host — to decide where to run, or to ask the user which server when ambiguous. Downloads/serves default to the cookbook's selected server, NOT localhost.",
|
||||
"app_api": "Generic loopback to allowed Odysseus internal endpoints. Use this when the user wants something the UI can do but there's no named tool for it. Covers calendar, gallery, library/documents, memory, notes, tasks, settings, research, compare, cookbook GPUs/state — allowed UI buttons hit /api/* endpoints and you can hit them too. Sensitive auth/user/admin/shell paths and host-control Cookbook mutation routes are blocked; do NOT use app_api for shell commands, package installs, engine rebuilds, or PID signalling. Use named command tooling for shell commands. action='endpoints' with filter=<keyword> lists available endpoints. action='call' takes method+path+body. Hits same routes the UI uses — auth flows free. NOTE: themes are NOT an API endpoint — use the ui_control tool (create_theme / set_theme), not app_api. SESSIONS/CHATS: do NOT use app_api for these — GET /api/sessions returns EMPTY for tool calls (it's owner-filtered and tool calls authenticate as a different identity). EMAIL ACCOUNTS: do NOT use /api/email/accounts via app_api; use list_email_accounts, list_emails, and read_email instead. To list/rename/archive/delete/fork chats use the list_sessions and manage_session tools instead.",
|
||||
"edit_image": "Edit an image in the gallery: upscale (increase resolution), remove background (rembg), inpaint (fill selected area), or harmonize (blend edits). Specify image ID and action.",
|
||||
"plan_edit": "Plan a multi-step edit for the open image editor and return ordered editor ops to run.",
|
||||
"run_editor_command": "Run one deterministic editor op (adjustments, masked filters, selection, layers, import/place, export, screenshot) on the open image editor.",
|
||||
"get_editor_screenshot": "Capture the open image editor's current canvas as a PNG data URL for inspection.",
|
||||
"ai_edit_image": "Apply an AI image edit (remove/inpaint objects, generative replace, remove-bg, upscale, denoise, enhance-face) to the open image and place the result as a new layer. Use for object removal/addition/replacement; never approximate those with selection/adjustment/filter ops.",
|
||||
"trigger_research": "Start a deep research job on any topic — appears in the Deep Research sidebar, streams progress, produces a detailed report. Use for 'research X', 'look into Y', 'do deep research on Z', 'investigate'. NOT a scheduled task — it runs now and surfaces in the sidebar.",
|
||||
"manage_bg_jobs": "Inspect and control detached background `bash` jobs (the ones started with a `#!bg` marker). action='list' shows this chat's jobs (id/status/age/command); action='output' returns a job's captured output so far (check on a long-running job, or re-read a finished one); action='kill' stops a runaway job by id. Use for 'is the background job done', 'check on that job', 'show the build output', 'kill the background job', 'stop the bg task'. output/kill need a job_id from list.",
|
||||
}
|
||||
|
||||
+25
-163
@@ -194,33 +194,10 @@ _TOOL_CODE_OPEN_RE = re.compile(r"<tool_code>\s*\{", re.IGNORECASE)
|
||||
_TOOL_CODE_CLOSE_RE = re.compile(r"\}\s*</tool_code>", re.IGNORECASE)
|
||||
|
||||
# Pattern 4b: Gemma-style <|tool_call|> call:tool_name{args} <tool_call|>
|
||||
_GEMMA_TOOL_CALL_OPEN_RE = re.compile(
|
||||
r"<\|?tool_call\|?>\s*call:([\w\d_-]+)\s*\{",
|
||||
_GEMMA_TOOL_CALL_RE = re.compile(
|
||||
r"<\|?tool_call\|?>\s*call:([\w\d_-]+)\s*(\{[\s\S]*?\})\s*<\|?tool_call\|?>",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_GEMMA_TOOL_CALL_CLOSE_RE = re.compile(
|
||||
r"\}\s*<\|?tool_call\|?>",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Native Qwen markup shares the same non-nesting delimiter grammar as the
|
||||
# XML helpers. Literal closers keep whitespace and opener floods linear;
|
||||
# values are stripped by the caller, as in the original regex path.
|
||||
_QWEN_FUNCTION_OPEN_RE = re.compile(r"<function=([A-Za-z_][\w:.-]*)>\s*")
|
||||
_QWEN_FUNCTION_CLOSE_RE = re.compile(r"</function>")
|
||||
_QWEN_PARAMETER_OPEN_RE = re.compile(r"<parameter=([A-Za-z_]\w*)>\s*")
|
||||
_QWEN_PARAMETER_CLOSE_RE = re.compile(r"</parameter>")
|
||||
_QWEN_PYTHON_ARG_KEY_RE = re.compile(r"[A-Za-z_]\w*")
|
||||
_QWEN_PYTHON_ARG_VALUE_RE = re.compile(r"\s*=\s*(['\"].*?['\"]|[^,]+)")
|
||||
_ANGLE_TAG_OPEN_RE = re.compile(r"<")
|
||||
_NONEMPTY_ANGLE_TAG_OPEN_RE = re.compile(r"<(?=[^>])")
|
||||
_ANGLE_TAG_CLOSE_RE = re.compile(r">")
|
||||
_EMAIL_LOCAL_RE = re.compile(r"[\w.+-]+")
|
||||
_EMAIL_ADDRESS_RE = re.compile(r"[\w.+-]+@[\w.-]+\.\w+")
|
||||
_ASCII_EMAIL_LOCAL_RE = re.compile(r"[A-Za-z0-9.!#$%&\x27*+/=?^_`{|}~-]+")
|
||||
_ASCII_EMAIL_ADDRESS_RE = re.compile(
|
||||
r"[A-Za-z0-9.!#$%&\x27*+/=?^_`{|}~-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"
|
||||
)
|
||||
|
||||
# Pattern 4c: Open-function wrapper emitted by some local MLX/Exo models.
|
||||
# Example:
|
||||
@@ -565,34 +542,6 @@ _PLAIN_UI_OPEN_PANEL_RE = re.compile(
|
||||
r"((?:\s+(?:day|week|month|year|agenda)(?:\s+view)?(?:\s+\d{4}-\d{2}(?:-\d{2})?)?)?)"
|
||||
r"\s*(?:`{1,3})?\s*$"
|
||||
)
|
||||
_PLAIN_UI_CANDIDATE_RE = re.compile(r"ui_control", re.IGNORECASE)
|
||||
|
||||
|
||||
def _iter_plain_ui_open_panel(text: str):
|
||||
"""Yield line-anchored UI commands without retrying every blank line."""
|
||||
pos = 0
|
||||
while candidate := _PLAIN_UI_CANDIDATE_RE.search(text, pos):
|
||||
line_start = text.rfind("\n", 0, candidate.start()) + 1
|
||||
match = _PLAIN_UI_OPEN_PANEL_RE.match(text, line_start)
|
||||
if match is not None:
|
||||
yield match
|
||||
pos = match.end()
|
||||
continue
|
||||
newline = text.find("\n", candidate.end())
|
||||
pos = len(text) if newline < 0 else newline + 1
|
||||
|
||||
|
||||
def _strip_plain_ui_open_panel(text: str) -> str:
|
||||
matches = list(_iter_plain_ui_open_panel(text))
|
||||
if not matches:
|
||||
return text
|
||||
out = []
|
||||
pos = 0
|
||||
for match in matches:
|
||||
out.append(text[pos:match.start()])
|
||||
pos = match.end()
|
||||
out.append(text[pos:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -909,10 +858,7 @@ def _raw_openai_tool_call_to_block(value) -> Optional[ToolBlock]:
|
||||
elif tool_type in ("grep", "glob", "ls", "edit_file"):
|
||||
content = json.dumps(args) if args else "{}"
|
||||
elif tool_type == "write_file":
|
||||
# Keep raw OpenAI JSON on the canonical path so explicit empty content
|
||||
# remains distinguishable from a missing body.
|
||||
from src.tool_schemas import function_call_to_tool_block
|
||||
return function_call_to_tool_block(name, json.dumps(args))
|
||||
content = args.get("path", "") + "\n" + args.get("content", "")
|
||||
elif tool_type == "create_document":
|
||||
parts = [args.get("title", "Untitled")]
|
||||
if args.get("language"):
|
||||
@@ -1043,43 +989,6 @@ def _strip_raw_openai_tool_call_json(text: str) -> str:
|
||||
return "".join(pieces)
|
||||
|
||||
|
||||
def iter_email_addresses(text: str, *, ascii_only: bool = False):
|
||||
"""Find legacy email-shaped strings without retrying every word suffix.
|
||||
|
||||
Match the address only at the start of each maximal local-part token.
|
||||
When that attempt fails, every suffix has the same '@'/domain boundary
|
||||
and must fail too. Local/domain character runs are each scanned a bounded
|
||||
number of times, including malformed text with no '@' or domain dot.
|
||||
"""
|
||||
local_re = _ASCII_EMAIL_LOCAL_RE if ascii_only else _EMAIL_LOCAL_RE
|
||||
address_re = _ASCII_EMAIL_ADDRESS_RE if ascii_only else _EMAIL_ADDRESS_RE
|
||||
pos = 0
|
||||
while token := local_re.search(text, pos):
|
||||
address = address_re.match(text, token.start())
|
||||
if address is not None:
|
||||
yield address.group(0)
|
||||
pos = address.end()
|
||||
else:
|
||||
pos = token.end()
|
||||
|
||||
|
||||
def _iter_qwen_python_args(raw_args: str):
|
||||
"""Match each maximal key once, then its value at that fixed position.
|
||||
|
||||
If a word has no following equals sign, none of its suffixes can have one
|
||||
either. Advancing past it avoids the old unanchored regex's O(n^2) retries
|
||||
on a long malformed key, while preserving permissive quoted/bare values.
|
||||
"""
|
||||
pos = 0
|
||||
while key := _QWEN_PYTHON_ARG_KEY_RE.search(raw_args, pos):
|
||||
value = _QWEN_PYTHON_ARG_VALUE_RE.match(raw_args, key.end())
|
||||
if value is not None:
|
||||
yield key.group(0), value.group(1)
|
||||
pos = value.end()
|
||||
else:
|
||||
pos = key.end()
|
||||
|
||||
|
||||
def _parse_qwen3_native_text_call(
|
||||
text: str,
|
||||
additional_tool_names: Optional[Iterable[str]] = None,
|
||||
@@ -1106,14 +1015,13 @@ def _parse_qwen3_native_text_call(
|
||||
|
||||
# Qwen native text rendering:
|
||||
# <tool_call><function=manage_notes><parameter=action>list</parameter>...
|
||||
fn_match = next(_iter_named_blocks(
|
||||
text, _QWEN_FUNCTION_OPEN_RE, _QWEN_FUNCTION_CLOSE_RE
|
||||
), None)
|
||||
fn_match = re.search(r"<function=([A-Za-z_][\w:.-]*)>\s*([\s\S]*?)\s*</function>", text)
|
||||
if fn_match:
|
||||
name, raw_body = fn_match
|
||||
name, raw_body = fn_match.groups()
|
||||
args = {}
|
||||
for key, raw_value in _iter_named_blocks(
|
||||
raw_body.rstrip(), _QWEN_PARAMETER_OPEN_RE, _QWEN_PARAMETER_CLOSE_RE,
|
||||
for key, raw_value in re.findall(
|
||||
r"<parameter=([A-Za-z_]\w*)>\s*([\s\S]*?)\s*</parameter>",
|
||||
raw_body,
|
||||
):
|
||||
value = raw_value.strip()
|
||||
if value and value[0] in "[{\"":
|
||||
@@ -1174,7 +1082,7 @@ def _parse_qwen3_native_text_call(
|
||||
or normalized_name in declared_names
|
||||
):
|
||||
args = {}
|
||||
for key, raw_value in _iter_qwen_python_args(raw_args):
|
||||
for key, raw_value in re.findall(r"([A-Za-z_]\w*)\s*=\s*(['\"].*?['\"]|[^,]+)", raw_args):
|
||||
try:
|
||||
args[key] = ast.literal_eval(raw_value.strip())
|
||||
except (ValueError, SyntaxError):
|
||||
@@ -1515,45 +1423,6 @@ def _parse_tool_code_block(raw: str) -> Optional[ToolBlock]:
|
||||
return ToolBlock(tool_name, content.strip())
|
||||
return None
|
||||
|
||||
_GEMMA_FALLBACK_KEY_RE = re.compile(r"\w++")
|
||||
_GEMMA_FALLBACK_COLON_RE = re.compile(r"\s*+:")
|
||||
_GEMMA_FALLBACK_BOUNDARY_RE = re.compile(r"(?<!\s)(\s*+)(?:,\s*+\w++\s*+:|})")
|
||||
|
||||
|
||||
def _iter_gemma_fallback_items(body):
|
||||
"""Preserve permissive k:v recovery with one pass over keys/boundaries."""
|
||||
boundaries = list(_GEMMA_FALLBACK_BOUNDARY_RE.finditer(body))
|
||||
boundary_index = 0
|
||||
pos = 0
|
||||
newline = body.find("\n")
|
||||
while key := _GEMMA_FALLBACK_KEY_RE.search(body, pos):
|
||||
colon = _GEMMA_FALLBACK_COLON_RE.match(body, key.end())
|
||||
if colon is None:
|
||||
pos = key.end()
|
||||
continue
|
||||
start = colon.end()
|
||||
while start < len(body) and body[start].isspace():
|
||||
start += 1
|
||||
if start < len(body) and body[start] in "\"'":
|
||||
start += 1
|
||||
while boundary_index < len(boundaries) and boundaries[boundary_index].end(1) < start:
|
||||
boundary_index += 1
|
||||
if boundary_index == len(boundaries):
|
||||
return
|
||||
boundary = boundaries[boundary_index]
|
||||
end = max(start, boundary.start())
|
||||
if 0 <= newline < start:
|
||||
newline = body.find("\n", start)
|
||||
if newline >= 0 and newline < end:
|
||||
pos = key.end()
|
||||
continue
|
||||
capture_end = end
|
||||
if capture_end > start and body[capture_end - 1] in "\"'":
|
||||
capture_end -= 1
|
||||
yield key.group(0), body[start:capture_end].strip()
|
||||
pos = end
|
||||
|
||||
|
||||
def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
|
||||
"""Parse a Gemma-style call:tool_name{...} block into a ToolBlock."""
|
||||
tool_name = tool_name.strip().lower().replace("-", "_")
|
||||
@@ -1578,7 +1447,12 @@ def _parse_gemma_tool_call(tool_name: str, body: str) -> Optional[ToolBlock]:
|
||||
if not isinstance(params, dict):
|
||||
params = {}
|
||||
except Exception:
|
||||
params = dict(_iter_gemma_fallback_items(body))
|
||||
# Simple regex key-value extraction fallback
|
||||
params = {}
|
||||
for m in re.finditer(r'(\w+)\s*:\s*["\']?(.*?)["\']?(?=\s*,\s*\w+\s*:|\s*\})', body):
|
||||
k = m.group(1)
|
||||
v = m.group(2).strip()
|
||||
params[k] = v
|
||||
|
||||
from src.tool_schemas import function_call_to_tool_block
|
||||
return function_call_to_tool_block(tool_name, json.dumps(params))
|
||||
@@ -1632,9 +1506,9 @@ def _iter_delimited(text, open_re, close_re):
|
||||
pos = cm.end()
|
||||
|
||||
|
||||
def _strip_delimited(text: str, open_re, close_re, replacement: str = "") -> str:
|
||||
"""Replace every ``open_re ... close_re`` span (forward-only; see
|
||||
_iter_delimited). Equivalent to ``open_re([\\s\\S]*?)close_re`` substitution
|
||||
def _strip_delimited(text: str, open_re, close_re) -> str:
|
||||
"""Remove every ``open_re ... close_re`` span (forward-only; see
|
||||
_iter_delimited). Equivalent to ``open_re([\\s\\S]*?)close_re`` ``re.sub('')``
|
||||
for these delimiters, without the O(n^2) rescan on unclosed openers."""
|
||||
spans = list(_iter_delimited(text, open_re, close_re))
|
||||
if not spans:
|
||||
@@ -1643,23 +1517,11 @@ def _strip_delimited(text: str, open_re, close_re, replacement: str = "") -> str
|
||||
last = 0
|
||||
for match_start, _inner_start, _inner_end, match_end in spans:
|
||||
out.append(text[last:match_start])
|
||||
out.append(replacement)
|
||||
last = match_end
|
||||
out.append(text[last:])
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def strip_angle_tags(text: str, replacement: str = "", *, allow_empty: bool = False) -> str:
|
||||
"""Linear equivalent of replacing ``<[^>]+>`` (or ``<[^>]*>``).
|
||||
|
||||
This preserves the existing flat text cleanup, including nested '<' and
|
||||
malformed tails; it does not interpret HTML. Stop once no '>' is reachable
|
||||
instead of retrying a suffix scan at every '<' in untrusted text.
|
||||
"""
|
||||
opener = _ANGLE_TAG_OPEN_RE if allow_empty else _NONEMPTY_ANGLE_TAG_OPEN_RE
|
||||
return _strip_delimited(text, opener, _ANGLE_TAG_CLOSE_RE, replacement)
|
||||
|
||||
|
||||
def _iter_named_blocks(text, open_re, close_re):
|
||||
"""Forward-only equivalent of ``open_re([\\s\\S]*?)close_re`` finditer where
|
||||
open_re captures a name in group 1: yield ``(name, body)``, pairing each
|
||||
@@ -2093,10 +1955,10 @@ def parse_tool_blocks(
|
||||
|
||||
# Pattern 4b: Gemma-style <|tool_call|> blocks
|
||||
if not blocks:
|
||||
for tool_name, body in _iter_named_blocks(
|
||||
text, _GEMMA_TOOL_CALL_OPEN_RE, _GEMMA_TOOL_CALL_CLOSE_RE
|
||||
):
|
||||
block = _parse_gemma_tool_call(tool_name, "{" + body + "}")
|
||||
for m in _GEMMA_TOOL_CALL_RE.finditer(text):
|
||||
tool_name = m.group(1)
|
||||
body = m.group(2)
|
||||
block = _parse_gemma_tool_call(tool_name, body)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
|
||||
@@ -2133,7 +1995,7 @@ def parse_tool_blocks(
|
||||
# from weaker native-tool models after reading the tool docs but failing to
|
||||
# emit the actual structured call.
|
||||
if not blocks:
|
||||
m = next(_iter_plain_ui_open_panel(text), None)
|
||||
m = _PLAIN_UI_OPEN_PANEL_RE.search(text)
|
||||
if m:
|
||||
blocks.append(ToolBlock("ui_control", f"open_panel {m.group(1).lower()}{m.group(2).lower()}".strip()))
|
||||
|
||||
@@ -2176,7 +2038,7 @@ def strip_tool_blocks(
|
||||
cleaned = _strip_delimited(cleaned, _XML_TOOL_CALL_OPEN_RE, _XML_TOOL_CALL_CLOSE_RE)
|
||||
cleaned = _XML_OPEN_TOOL_CALL_RE.sub('', cleaned)
|
||||
cleaned = _strip_delimited(cleaned, _TOOL_CODE_OPEN_RE, _TOOL_CODE_CLOSE_RE)
|
||||
cleaned = _strip_delimited(cleaned, _GEMMA_TOOL_CALL_OPEN_RE, _GEMMA_TOOL_CALL_CLOSE_RE)
|
||||
cleaned = _GEMMA_TOOL_CALL_RE.sub('', cleaned)
|
||||
cleaned = _strip_delimited(cleaned, _FUNCTION_MODEL_OPEN_RE, _FUNCTION_MODEL_CLOSE_RE)
|
||||
cleaned = _strip_raw_openai_tool_call_json(cleaned)
|
||||
declared_xml_calls = _parse_declared_direct_xml_calls(
|
||||
@@ -2195,7 +2057,7 @@ def strip_tool_blocks(
|
||||
if raw_web_json:
|
||||
_, (start, end) = raw_web_json
|
||||
cleaned = cleaned[:start] + cleaned[end:]
|
||||
cleaned = _strip_plain_ui_open_panel(cleaned)
|
||||
cleaned = _PLAIN_UI_OPEN_PANEL_RE.sub("", cleaned)
|
||||
# Strip bare <invoke> blocks not wrapped in <tool_call>
|
||||
cleaned = _strip_bare_invoke_markup(cleaned)
|
||||
cleaned = re.sub(r'\n{3,}', '\n\n', cleaned)
|
||||
|
||||
+76
-13
@@ -70,7 +70,7 @@ def _repair_function_arg_aliases(tool_type: str, args: dict[str, Any]) -> dict[s
|
||||
command = args.get("command")
|
||||
if isinstance(command, str):
|
||||
path, separator, content = command.partition("\n")
|
||||
if separator and path.strip() and content.strip():
|
||||
if separator and path.strip() and content:
|
||||
args["path"] = path.strip()
|
||||
args["content"] = content
|
||||
logger.warning("Normalized legacy `command` write shape to path/content")
|
||||
@@ -494,14 +494,14 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "youtube_tool",
|
||||
"description": "Read YouTube-specific data without fighting the JS UI. Use for YouTube video comments, transcripts, metadata, or latest video from a channel. For general web lookup use web_search; for interacting with the visible site use private_browser.",
|
||||
"description": "Read YouTube-specific data (comments, transcripts, metadata, or latest video from a channel) without fighting the YouTube JS UI. YOUTUBE ONLY: it accepts only youtube.com or youtu.be links — a video URL, a shorts/live link, or an 11-character video_id, or a channel /@handle/videos URL. It does NOT accept any other website. For a non-YouTube URL do not use this tool: use web_fetch for one specific page or web_search for an open-ended lookup; use private_browser only to drive the visible browser.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {"type": "string", "enum": ["comments", "transcript", "metadata", "latest_channel_video"], "description": "What YouTube data to retrieve. Use latest_channel_video with max_results for latest N uploads from a channel."},
|
||||
"url": {"type": "string", "description": "YouTube video or channel URL"},
|
||||
"video_url": {"type": "string", "description": "YouTube video URL"},
|
||||
"video_id": {"type": "string", "description": "YouTube video id"},
|
||||
"url": {"type": "string", "description": "A youtube.com or youtu.be URL (video, watch?v=, shorts, or a channel /@handle/videos URL). No other hosts."},
|
||||
"video_url": {"type": "string", "description": "A youtube.com or youtu.be video URL"},
|
||||
"video_id": {"type": "string", "description": "The 11-character YouTube video id (e.g. jNQXAC9IVRw)"},
|
||||
"channel_url": {"type": "string", "description": "YouTube channel URL or /@handle/videos URL"},
|
||||
"handle": {"type": "string", "description": "YouTube handle, e.g. @OpenAI"},
|
||||
"max_results": {"type": "integer", "description": "Maximum comments, uploads, or results to return, capped at 50"}
|
||||
@@ -1497,7 +1497,7 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "generate_image",
|
||||
"description": "Generate an AI image from a text prompt and save it to the gallery. If this tool reports that no image model or endpoint is configured, report that limitation directly; do not substitute Bash, Python, SVG, or another tool.",
|
||||
"description": "Generate a NEW image from a text prompt and place it as a layer in the open editor or save it to the gallery. Use for adding content that is not already in the photo (e.g. 'add a full moon in the sky'). If this tool reports that no image model or endpoint is configured, report that limitation directly; do NOT substitute deterministic editor ops (selection/adjustments/filters), Bash, Python, or SVG.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1527,6 +1527,75 @@ FUNCTION_TOOL_SCHEMAS = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "plan_edit",
|
||||
"description": "Plan a photo-editing program for the currently open image editor. Returns an ordered list of editor ops to run against the canvas. Route object removal, added content, or replaced content to ai_edit_image/generate_image; use deterministic ops only for tone/colour/filter/crop/text/selection edits. If no image provider is configured for a generative request, say so instead of planning a substitute deterministic edit. Use the canonical adjustment/filter shapes (adjustments.apply/applyMasked params {\"type\", \"params\"}; filters.apply/applyMasked params {\"id\"}); never invent adjustment types or filter ids. New ops: photo.autoTone; retouch.skin/blemish/teeth; selection.face/background. Portrait/look-better ('fix the skin', 'fix my teeth') -> selection.face then retouch.skin (and retouch.teeth for teeth) then ONE gentle photo.autoTone; never a timid single step. Low-key/'fix the lighting' on a dark image: at most ONE global lift (prefer photo.autoTone once or shadows-highlights); never stack autoTone/brightness/exposure/levels, and keep highlights <= 0. Temperature sign: moody/cool/cinematic -> white-balance temperature NEGATIVE; warm/golden/sunset -> POSITIVE. Background softer -> selection.background then filters.applyMasked {id:'blur-gaussian'} (never a plain global blur). A vague prompt with no concrete defect ('clean this up') -> return {\"reply\": <question>, \"steps\": [], \"ask_user\": {\"question\":..., \"options\":[...]}} with 2-3 concrete options instead of guessing.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {"type": "string", "description": "What the user wants done to the image"},
|
||||
"state": {"type": "object", "description": "Optional current editor state (layers, selection, document info)"}
|
||||
},
|
||||
"required": ["prompt"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_editor_command",
|
||||
"description": "Run a single deterministic operation on the open image editor and return its result. Use catalog ops such as adjustments.*, filters.applyMasked, selection.*, layers.*, document.importImage, layers.placeImage, export.png, screenshot. Do NOT use for AI/masked edits (use ai_edit_image) or new imagery (use generate_image). Canonical shapes: adjustments.apply/applyMasked params = {\"type\": <brightness-contrast|exposure|white-balance|hue-saturation|vibrance|black-white|shadows-highlights|levels|curves|color-balance|photo-filter|selective-color|gradient-map|invert|posterize|threshold|channel-mixer|color-lookup>, \"params\": {...}}; filters.apply/applyMasked params = {\"id\": <blur-gaussian|blur-motion|blur-zoom|sharpen|sharpen-edges|unsharp-mask|noise-add|noise-despeckle|pixelate-mosaic|pixelate-crystallize|stylize-emboss|stylize-find-edges|stylize-solarize|distort-twirl|distort-pinch|distort-spherize|distort-ripple|render-clouds>}. Never nest a numeric bag under {\"adjustments\": {...}} and never invent adjustment types or filter ids (no \"vignette\" or \"clarity\"). Additional canonical ops: photo.autoTone {}; retouch.skin {smooth,opacity,blemish,radius,threshold}; retouch.blemish {radius,threshold}; retouch.teeth {desat,bright}; selection.face {mode}; selection.background {mode,subject,fuzziness}. At most one global brightness/exposure lift (prefer shadows-highlights; keep highlights <= 0). Never stack global lifts.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"op": {"type": "string", "description": "Editor op name from the catalog, e.g. adjustments.applyMasked"},
|
||||
"params": {"type": "object", "description": "Parameters for the op"},
|
||||
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"},
|
||||
"wait": {"type": "boolean", "description": "Wait for the editor result (default true)"}
|
||||
},
|
||||
"required": ["op"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_editor_screenshot",
|
||||
"description": "Capture the open image editor's current canvas as a PNG data URL. Use to inspect the result before choosing the next edit.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"session": {"type": "string", "description": "Editor session id (defaults to the open session)"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ai_edit_image",
|
||||
"description": "Apply an AI image edit and place the result as a NEW layer, never overwriting the original. Two modes. mode='instruction' (default): send the WHOLE image with a clear prompt and no mask — use a rephrased instruction that NAMES what must stay unchanged, e.g. 'replace the background with a sunny beach, keep the woman exactly the same'. Use this for background/scene replacement, changing the season or style, adding an object to the scene (a moon, a person), and removing diffuse distractions. mode='masked': masked inpaint on a selected region — use ONLY for an explicit regional removal of a clearly identified object ('remove the person on the left'); select the object first so the mask is tight. NEVER approximate object removal/addition/replacement with selection, adjustment, filter or transform ops; if no image provider is configured, report that instead of substituting a deterministic edit. Other ops: remove-bg, upscale (2x/4x), denoise, enhance-face.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"op": {"type": "string", "enum": ["remove-bg", "upscale", "denoise", "enhance-face", "inpaint", "generative"], "description": "AI edit operation"},
|
||||
"mode": {"type": "string", "enum": ["instruction", "masked"], "description": "instruction = whole-image edit with no mask (background/season/style/add-object/diffuse removal); masked = tight-mask regional removal of a selected object. Default instruction."},
|
||||
"prompt": {"type": "string", "description": "Instruction for inpaint/generative (in instruction mode, name what must stay unchanged)"},
|
||||
"image": {"type": "string", "description": "Optional source (data URL or gallery image id); defaults to the current canvas/screenshot"},
|
||||
"mask": {"type": "string", "description": "Optional base64 PNG mask (white = region to edit); required for mode='masked'"},
|
||||
"scale": {"type": "number", "description": "Upscale factor (2 or 4)"},
|
||||
"strength": {"type": "number", "description": "Denoise strength (0-1)"},
|
||||
"steps": {"type": "integer", "description": "Optional sampler steps. Use 8 for removal/expand edits (default 4 is too weak for those)."},
|
||||
"place": {"type": "boolean", "description": "Place the result as a new layer (default true)"},
|
||||
"model": {"type": "string", "description": "Optional image model id to route this edit to (from GET /api/editor/image-models); omit to use the configured default"},
|
||||
"session": {"type": "string", "description": "Editor session id"}
|
||||
},
|
||||
"required": ["op"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -2200,13 +2269,7 @@ def function_call_to_tool_block(name: str, arguments: str) -> Optional[ToolBlock
|
||||
elif tool_type == "get_workspace":
|
||||
content = ""
|
||||
elif tool_type == "write_file":
|
||||
body = args.get("content")
|
||||
if isinstance(body, str) and body.strip():
|
||||
content = args.get("path", "") + "\n" + body
|
||||
else:
|
||||
# Preserve missing/empty intent for WriteFileTool instead of folding
|
||||
# all empty shapes into the same path-plus-newline representation.
|
||||
content = json.dumps(args)
|
||||
content = args.get("path", "") + "\n" + args.get("content", "")
|
||||
elif tool_type == "edit_file":
|
||||
content = json.dumps(args)
|
||||
elif tool_type == "apply_patch":
|
||||
|
||||
+2
-13
@@ -127,6 +127,7 @@ PLAN_MODE_READONLY_TOOLS = {
|
||||
"resolve_contact",
|
||||
"chat_with_model",
|
||||
"ask_teacher",
|
||||
"get_editor_screenshot",
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +164,7 @@ _PLAN_MODE_KNOWN_MUTATORS = {
|
||||
"download_model", "serve_model",
|
||||
"stop_served_model", "cancel_download", "adopt_served_model", "serve_preset",
|
||||
"generate_image", "edit_image", "trigger_research", "manage_research",
|
||||
"plan_edit", "run_editor_command", "ai_edit_image",
|
||||
# Shell is never read-only-safe; block it explicitly so it stays out of plan
|
||||
# mode even if the schema list fails to load.
|
||||
"bash", "python",
|
||||
@@ -274,16 +276,3 @@ def blocked_tools_for_owner(owner: Optional[str]) -> Set[str]:
|
||||
if owner_is_admin_or_single_user(owner):
|
||||
return set()
|
||||
return set(NON_ADMIN_BLOCKED_TOOLS)
|
||||
|
||||
|
||||
def delegated_credential_blocked_tools() -> Set[str]:
|
||||
"""Tools an agent run driven by a bearer API token must not reach.
|
||||
|
||||
Deliberately not owner-dependent. ``blocked_tools_for_owner`` asks whether
|
||||
the OWNER is an admin, and for a token that question is always answered
|
||||
yes: minting a token is an admin-only action, so the empty set comes back
|
||||
for every token in existence. A token is a long-lived credential the owner
|
||||
hands to a third party, so it is capped at the non-admin policy no matter
|
||||
who minted it.
|
||||
"""
|
||||
return set(NON_ADMIN_BLOCKED_TOOLS)
|
||||
|
||||
@@ -28,5 +28,6 @@ TOOL_TAGS = {
|
||||
"tail_serve_output", "list_downloads", "cancel_download", "search_hf_models",
|
||||
"list_cached_models", "list_serve_presets", "serve_preset", "adopt_served_model",
|
||||
"list_cookbook_servers", "edit_image", "trigger_research", "manage_research",
|
||||
"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image",
|
||||
"app_api",
|
||||
} | BUILTIN_EMAIL_TOOLS
|
||||
|
||||
@@ -227,7 +227,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
desc = str(raw_args.get("description") or "")
|
||||
if re.search(r"\b(remind|reminder|alarm)\b", desc, re.I):
|
||||
raw = desc
|
||||
if raw in (None, "", False):
|
||||
if raw is None or raw == "" or raw is False:
|
||||
return None
|
||||
if raw is True:
|
||||
return 10
|
||||
@@ -236,10 +236,10 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
text = str(raw).strip().lower()
|
||||
if text in {"none", "no", "off", "false"}:
|
||||
return None
|
||||
m = re.search(r"(?<!\d)(\d+)\s*(?:minutes?|mins?|m)\b", text)
|
||||
m = re.search(r"(\d+)\s*(?:minutes?|mins?|m)\b", text)
|
||||
if m:
|
||||
return max(0, int(m.group(1)))
|
||||
m = re.search(r"(?<!\d)(\d+)\s*(?:hours?|hrs?|h)\b", text)
|
||||
m = re.search(r"(\d+)\s*(?:hours?|hrs?|h)\b", text)
|
||||
if m:
|
||||
return max(0, int(m.group(1)) * 60)
|
||||
if text.isdigit():
|
||||
@@ -251,7 +251,7 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
if minutes_before is None:
|
||||
return desc
|
||||
reminder_only = re.compile(
|
||||
r"^\s*(?:remind(?:er)?|alarm)\s*(?::\s*)?\d+\s*"
|
||||
r"^\s*(?:remind(?:er)?|alarm)\s*:?\s*\d+\s*"
|
||||
r"(?:minutes?|mins?|m|hours?|hrs?|h)\b.*$",
|
||||
re.I,
|
||||
)
|
||||
@@ -497,8 +497,8 @@ async def do_manage_calendar(content: str, owner: Optional[str] = None, *, impor
|
||||
delta = None
|
||||
if dur:
|
||||
import re as _re_d
|
||||
h = _re_d.search(r'(?<!\d)(\d+)\s*(?:h|hr|hours?)', dur)
|
||||
m = _re_d.search(r'(?<!\d)(\d+)\s*(?:m|min|minutes?)', dur)
|
||||
h = _re_d.search(r'(\d+)\s*(?:h|hr|hours?)', dur)
|
||||
m = _re_d.search(r'(\d+)\s*(?:m|min|minutes?)', dur)
|
||||
secs = (int(h.group(1)) * 3600 if h else 0) + (int(m.group(1)) * 60 if m else 0)
|
||||
if secs > 0:
|
||||
delta = timedelta(seconds=secs)
|
||||
|
||||
+1
-1
@@ -322,7 +322,7 @@ async def do_manage_notes(content: str, owner: Optional[str] = None) -> Dict:
|
||||
if looks_like_reminder:
|
||||
temporal = re.search(
|
||||
r"\b(?:today|tonight|tomorrow|tmrw|yesterday)\b(?:\s+(?:at\s+)?\d{1,2}(?::\d{2})?\s*(?:am|pm)?)?"
|
||||
r"|\b\d{1,2}(?::\d{2})?(?:\s*(?:am|pm))?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b"
|
||||
r"|\b\d{1,2}(?::\d{2})?\s*(?:am|pm)?\s+(?:today|tonight|tomorrow|tmrw|yesterday)\b"
|
||||
r"|\bin\s+\d+\s*(?:hour|hr|minute|min|day)s?\b",
|
||||
lower_combined,
|
||||
)
|
||||
|
||||
+146
-269
@@ -17,7 +17,6 @@ from typing import Iterable, Mapping
|
||||
|
||||
from src.action_intents import classify_tool_intent
|
||||
from src.tool_policy import ToolPolicy
|
||||
from src.text_scanning import has_prefixed_token_match
|
||||
|
||||
|
||||
FAMILY_TOOLS = {
|
||||
@@ -37,6 +36,7 @@ FAMILY_TOOLS = {
|
||||
"sessions": frozenset({"list_sessions", "manage_session", "create_session", "send_to_session", "chat_with_model", "pipeline"}),
|
||||
"image_generation": frozenset({"generate_image"}),
|
||||
"image_editing": frozenset({"edit_image"}),
|
||||
"photo_editor": frozenset({"plan_edit", "run_editor_command", "get_editor_screenshot", "ai_edit_image", "generate_image"}),
|
||||
"transcription": frozenset({"transcribe_media"}),
|
||||
"media_inspection": frozenset({"inspect_media"}),
|
||||
"ocr": frozenset({"extract_text"}),
|
||||
@@ -48,62 +48,6 @@ FAMILY_TOOLS = {
|
||||
CONTRACT_CORE_TOOLS = frozenset({
|
||||
"bash", "python", "read_file", "web_search", "web_fetch", "ask_user",
|
||||
})
|
||||
|
||||
_WORKSPACE_PREFIX_RE = re.compile(r"/workspace/", re.I)
|
||||
_WORKSPACE_ARTIFACT_RE = re.compile(
|
||||
r"/workspace/[^\s`\"']+\.(?:csv|html?|json|md|svg|txt)\b", re.I
|
||||
)
|
||||
_WORKSPACE_OUTPUT_PREFIX_RE = re.compile(r"/workspace/(?!input/)", re.I)
|
||||
_WORKSPACE_OUTPUT_RE = re.compile(
|
||||
r"/workspace/(?!input/)[^\s`\"']+\."
|
||||
r"(?:csv|html?|json|md|svg|txt|avif|bmp|gif|jpe?g|png|webp|pdf|mp4|webm)\b",
|
||||
re.I,
|
||||
)
|
||||
_WORKSPACE_TOKEN_TAIL_RE = re.compile(r"[^\s`\"']*")
|
||||
|
||||
|
||||
def _mentions_workspace_artifact(text: str) -> bool:
|
||||
return has_prefixed_token_match(
|
||||
str(text or ""),
|
||||
_WORKSPACE_PREFIX_RE,
|
||||
_WORKSPACE_ARTIFACT_RE,
|
||||
_WORKSPACE_TOKEN_TAIL_RE,
|
||||
)
|
||||
|
||||
|
||||
def _mentions_workspace_output(text: str) -> bool:
|
||||
return has_prefixed_token_match(
|
||||
str(text or ""),
|
||||
_WORKSPACE_OUTPUT_PREFIX_RE,
|
||||
_WORKSPACE_OUTPUT_RE,
|
||||
_WORKSPACE_TOKEN_TAIL_RE,
|
||||
)
|
||||
|
||||
|
||||
def _mentions_under_budget(text: str) -> bool:
|
||||
value = str(text or "")
|
||||
for under in re.finditer(r"\bunder", value, re.I):
|
||||
cursor = under.end()
|
||||
if cursor >= len(value) or not value[cursor].isspace():
|
||||
continue
|
||||
while cursor < len(value) and value[cursor].isspace():
|
||||
cursor += 1
|
||||
if cursor < len(value) and value[cursor] in "¥$€£":
|
||||
cursor += 1
|
||||
while cursor < len(value) and value[cursor].isspace():
|
||||
cursor += 1
|
||||
digit_start = cursor
|
||||
while cursor < len(value) and value[cursor].isdecimal():
|
||||
cursor += 1
|
||||
if cursor == digit_start:
|
||||
continue
|
||||
if cursor == len(value) or not (value[cursor].isalnum() or value[cursor] == "_"):
|
||||
return True
|
||||
if value[cursor:cursor + 3].casefold() == "yen":
|
||||
cursor += 3
|
||||
if cursor == len(value) or not (value[cursor].isalnum() or value[cursor] == "_"):
|
||||
return True
|
||||
return False
|
||||
_FAMILY_WORDS = {
|
||||
"calendar": r"\b(?:calendar|calender|events?|appointments?|meetings?|agenda)\b",
|
||||
"notes": r"\b(?:notes?|checklists?|groceries|remind\s+me)\b",
|
||||
@@ -179,7 +123,13 @@ def _normalize_request_lead(value: str) -> str:
|
||||
text,
|
||||
flags=re.I,
|
||||
)
|
||||
text = _strip_cancelled_request_lead(text)
|
||||
text = re.sub(
|
||||
r"^(?:never\s*mind|scratch\s+that)\s*[,;:—–-]?\s*"
|
||||
r"(?=(?:open|show|list|read|search|find|check|switch|go)\b)",
|
||||
"",
|
||||
text,
|
||||
flags=re.I,
|
||||
)
|
||||
text = re.sub(r"^k(?:ay)?\s*[,!]?\s+(?=\S)", "", text, flags=re.I)
|
||||
text = re.sub(
|
||||
r"^(?:(?:great|nice|cool)\s*[,!.]|thanks?\s*[.!])\s+"
|
||||
@@ -214,21 +164,6 @@ def _normalize_request_lead(value: str) -> str:
|
||||
text = re.sub(r"^((?:can|could|would|will)\s+)u\b", r"\1you", text, flags=re.I)
|
||||
text = re.sub(r"\boffical\b", "official", text, flags=re.I)
|
||||
return text
|
||||
|
||||
|
||||
def _strip_cancelled_request_lead(text: str) -> str:
|
||||
lead = re.match(r"^(?:never\s*mind|scratch\s+that)", text, re.I)
|
||||
if lead is None:
|
||||
return text
|
||||
cursor = lead.end()
|
||||
while cursor < len(text) and text[cursor].isspace():
|
||||
cursor += 1
|
||||
if cursor < len(text) and text[cursor] in ",;:—–-":
|
||||
cursor += 1
|
||||
while cursor < len(text) and text[cursor].isspace():
|
||||
cursor += 1
|
||||
action = re.match(r"(?:open|show|list|read|search|find|check|switch|go)\b", text[cursor:], re.I)
|
||||
return text[cursor:] if action is not None else text
|
||||
_MISSPELLED_RESEARCH_ACTION = re.compile(
|
||||
r"^\s*" + _REQUEST_PREFIX + r"(?:reserch|reasearch|reseach)\b",
|
||||
re.I,
|
||||
@@ -261,21 +196,6 @@ _PURE_ACTION_PROHIBITION = re.compile(
|
||||
r"[^.;\n]*[.!?]*\s*$",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _is_pure_action_prohibition(text: str) -> bool:
|
||||
value = str(text or "").strip()
|
||||
prefix = re.match(
|
||||
r"(?:read[- ]only(?:\s+and)?\s+)?(?:do\s+not|don['’]?t|never)\s+"
|
||||
r"(?:add|create|make|write|draft|edit|change|update|delete|remove|send|reply|"
|
||||
r"run|execute|download|serve|open|save|schedule|transcribe|inspect)\b",
|
||||
value,
|
||||
re.I,
|
||||
)
|
||||
if prefix is None:
|
||||
return False
|
||||
tail = value[prefix.end():].rstrip(".!?")
|
||||
return not any(char in ".;\n" for char in tail)
|
||||
_RETURN_TO_ACTION = re.compile(r"^\s*" + _REQUEST_PREFIX + r"return\s+to\b", re.I)
|
||||
_PANEL_NAVIGATION = re.compile(
|
||||
r"^\s*" + _REQUEST_PREFIX
|
||||
@@ -487,7 +407,8 @@ _BOUND_EDITOR_WRITE = re.compile(
|
||||
re.I,
|
||||
)
|
||||
_BOUND_EDITOR_IMPLICIT_REVISION = re.compile(
|
||||
r"^\s*" + _REQUEST_PREFIX + r"(?:broaden|expand|deepen|lighten|go\s+deeper|"
|
||||
r"^\s*" + _REQUEST_PREFIX + r"(?:broaden|expand|deepen|lighten|go\s+deeper|go\s+more\s+into|"
|
||||
r"g[aå]\s+mer\s+(?:in|ut)\s+p[aå]|"
|
||||
r"clean\s+(?:this|it|the\s+(?:text|draft|document|doc))\s+up|"
|
||||
r"give\s+(?:me\s+)?(?:feedback|a\s+critique|suggestions?|comments?)|"
|
||||
r"remove\b[^.;\n]{0,100}\b(?:mistakes?|errors?|inaccurac(?:y|ies)|misinformation)|"
|
||||
@@ -528,111 +449,11 @@ _WARM_RECALL_WITH_FOLLOWUP = re.compile(
|
||||
r"(?P<followup>(?:what(?:['’]?s|\s+is)?|which|who|where|when|how|show|open|read|list|find|search)\b[\s\S]{0,180})$",
|
||||
re.I,
|
||||
)
|
||||
|
||||
_WARM_TARGET_RE = re.compile(
|
||||
r"calendar|emails?|inbox|notes?|tasks?|skills?|memories|memory|"
|
||||
r"documents?|docs?|web|browser|cookbook|files?|shell",
|
||||
re.I,
|
||||
)
|
||||
_WARM_FOLLOWUP_RE = re.compile(
|
||||
r"(?:what(?:['’]?s|\s+is)?|which|who|where|when|how|show|open|read|list|find|search)"
|
||||
r"\b[\s\S]{0,180}\Z",
|
||||
re.I,
|
||||
)
|
||||
|
||||
|
||||
def _consume_space(value: str, cursor: int, *, required: bool = False) -> int | None:
|
||||
start = cursor
|
||||
while cursor < len(value) and value[cursor].isspace():
|
||||
cursor += 1
|
||||
return None if required and cursor == start else cursor
|
||||
|
||||
|
||||
def _phrase_end(value: str, cursor: int, phrase: str) -> int | None:
|
||||
for index, word in enumerate(phrase.split(" ")):
|
||||
if value[cursor:cursor + len(word)].casefold() != word:
|
||||
return None
|
||||
cursor += len(word)
|
||||
if index + 1 < len(phrase.split(" ")):
|
||||
cursor = _consume_space(value, cursor, required=True)
|
||||
if cursor is None:
|
||||
return None
|
||||
return cursor
|
||||
|
||||
|
||||
def _warm_recall_parts(value: str, *, with_followup: bool = False) -> tuple[str, str] | None:
|
||||
value = str(value or "")
|
||||
cursor = _consume_space(value, 0) or 0
|
||||
states = [cursor]
|
||||
discourse = re.match(r"(?:ok(?:ay)?|and|then)", value[cursor:], re.I)
|
||||
if discourse is not None:
|
||||
after = _consume_space(value, cursor + discourse.end(), required=True)
|
||||
if after is not None:
|
||||
states.insert(0, after)
|
||||
|
||||
action_phrases = ("back to", "return to", "what about", "check", "show", "open")
|
||||
action_states: list[int] = []
|
||||
for state in states:
|
||||
if not with_followup:
|
||||
action_states.append(state)
|
||||
for phrase in action_phrases:
|
||||
end = _phrase_end(value, state, phrase)
|
||||
if end is None:
|
||||
continue
|
||||
if with_followup:
|
||||
end = _consume_space(value, end, required=True)
|
||||
if end is None:
|
||||
continue
|
||||
action_states.append(end)
|
||||
|
||||
for state in dict.fromkeys(action_states):
|
||||
state = _consume_space(value, state) or 0
|
||||
possessive_states = [state]
|
||||
for possessive in ("my", "the"):
|
||||
if value[state:state + len(possessive)].casefold() == possessive:
|
||||
possessive_states.insert(0, state + len(possessive))
|
||||
for target_state in possessive_states:
|
||||
target_state = _consume_space(value, target_state) or 0
|
||||
target = _WARM_TARGET_RE.match(value, target_state)
|
||||
if target is None:
|
||||
continue
|
||||
target_text = target.group(0)
|
||||
suffix = target.end()
|
||||
if with_followup:
|
||||
if suffix < len(value) and (value[suffix].isalnum() or value[suffix] == "_"):
|
||||
continue
|
||||
separator_states = []
|
||||
spaced = _consume_space(value, suffix) or 0
|
||||
if spaced > suffix:
|
||||
separator_states.append(spaced)
|
||||
if spaced < len(value) and value[spaced] in "-—,:;":
|
||||
separator_states.append(_consume_space(value, spaced + 1) or 0)
|
||||
for conjunction in ("and", "then"):
|
||||
end = spaced + len(conjunction)
|
||||
if (value[spaced:end].casefold() == conjunction
|
||||
and (end == len(value) or not (value[end].isalnum() or value[end] == "_"))):
|
||||
separator_states.append(_consume_space(value, end) or 0)
|
||||
for followup_start in dict.fromkeys(separator_states):
|
||||
followup = _WARM_FOLLOWUP_RE.match(value, followup_start)
|
||||
if followup is not None:
|
||||
return target_text, followup.group(0)
|
||||
continue
|
||||
suffix = _consume_space(value, suffix) or 0
|
||||
suffix_states = [suffix]
|
||||
for word in ("again", "now"):
|
||||
if value[suffix:suffix + len(word)].casefold() == word:
|
||||
suffix_states.insert(0, suffix + len(word))
|
||||
for end in suffix_states:
|
||||
while end < len(value) and value[end] in ".!?":
|
||||
end += 1
|
||||
end = _consume_space(value, end) or 0
|
||||
if end == len(value):
|
||||
return target_text, ""
|
||||
return None
|
||||
_REQUIRED_TOOLS = {
|
||||
"calendar": "manage_calendar", "notes": "manage_notes",
|
||||
"tasks": "manage_tasks", "skills": "manage_skills",
|
||||
"image_generation": "generate_image", "image_editing": "edit_image",
|
||||
"photo_editor": "plan_edit",
|
||||
"transcription": "transcribe_media", "media_inspection": "inspect_media", "ocr": "extract_text",
|
||||
}
|
||||
|
||||
@@ -895,6 +716,60 @@ def scheduled_automation_request(message: str) -> bool:
|
||||
))
|
||||
|
||||
|
||||
_TASK_OBJECT = (
|
||||
r'(?:tasks?|to-?dos?|to-?do\s+lists?|task\s+lists?|reminders?|automations?|'
|
||||
r'(?:schedul\w*|recurring|scheduled)\s+jobs?)'
|
||||
)
|
||||
_TASK_MANAGEMENT_VERB = (
|
||||
r'(?:add|create|make|set\s+up|schedule|new|start|track|turn|convert|save|store|'
|
||||
r'delete|remove|complete|finish|close|pause|resume|restart|re-?run|run|edit|'
|
||||
r'update|rename|archive|cancel|list|show|view|see|check|manage|assign|mark|'
|
||||
r'look|peek|pull\s+up|open|find|keep)'
|
||||
)
|
||||
|
||||
|
||||
def explicit_task_management(message: str) -> bool:
|
||||
"""True only when the user explicitly asks to manage background tasks.
|
||||
|
||||
A bare project noun ("30-day task + protocol doc") describes work to do, not
|
||||
a request to create or read a scheduled task, so it must not select the
|
||||
tasks family or offer manage_tasks.
|
||||
"""
|
||||
text = _normalize_request_lead(message)
|
||||
if not text:
|
||||
return False
|
||||
if scheduled_automation_request(text):
|
||||
return True
|
||||
# Possessive / near-noun references: "my tasks", "our reminders", "my jobs".
|
||||
if re.search(
|
||||
r'\b(?:my|our|the|any|all)\b[^.!?\n]{0,24}?\b' + _TASK_OBJECT + r'\b',
|
||||
text, re.I,
|
||||
):
|
||||
return True
|
||||
# An explicit management verb near a task noun: "add a task", "delete my todo".
|
||||
if re.search(
|
||||
r'\b' + _TASK_MANAGEMENT_VERB + r'\b[^.!?\n]{0,40}?\b' + _TASK_OBJECT + r'\b',
|
||||
text, re.I,
|
||||
):
|
||||
return True
|
||||
# A task noun with an interrogative/state tail: "tasks do I have", "tasks titled".
|
||||
if re.search(
|
||||
r'\b' + _TASK_OBJECT + r'\b[^.!?\n]{0,32}?'
|
||||
r'\b(?:do\s+i\s+have|have\s+i|are\s+(?:set\s+up|scheduled|active)|'
|
||||
r'titled|named|called|set\s+up|scheduled|active|due|overdue)\b',
|
||||
text, re.I,
|
||||
):
|
||||
return True
|
||||
# A task noun tied to a list/tracker surface.
|
||||
if re.search(
|
||||
r'\b' + _TASK_OBJECT + r'\b[^.!?\n]{0,24}?\b'
|
||||
r'(?:list|tracker|manager|dashboard|schedule|inbox|library|board)\b',
|
||||
text, re.I,
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def creation_container_tool(message: str) -> str | None:
|
||||
"""The explicitly created container owns its content, not vice versa."""
|
||||
text = _normalize_request_lead(message)
|
||||
@@ -1033,7 +908,11 @@ def selected_tools_for_request(message: str) -> frozenset[str] | None:
|
||||
tools = {"inspect_media"}
|
||||
if (
|
||||
re.search(r"\b(?:create|write|save|build|produce)\b", raw_text, re.I)
|
||||
and _mentions_workspace_artifact(raw_text)
|
||||
and re.search(
|
||||
r"(?:file://)?/workspace/[^\s`\"']+\.(?:csv|html?|json|md|svg|txt)\b",
|
||||
raw_text,
|
||||
re.I,
|
||||
)
|
||||
):
|
||||
tools.update({"write_file", "read_file"})
|
||||
if re.search(r"\b(?:preview|render|open)\b[^.\n]{0,100}\b(?:page|html|browser)\b", raw_text, re.I):
|
||||
@@ -2296,34 +2175,6 @@ _READ_PRESENTATION_SUFFIX = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _terminal_clause_match(text: str, core_pattern: str) -> re.Match[str] | None:
|
||||
"""Match a terminal clause after removing its ambiguous punctuation tail."""
|
||||
end = len(text)
|
||||
while end and text[end - 1].isspace():
|
||||
end -= 1
|
||||
while end and text[end - 1] in ".!?":
|
||||
end -= 1
|
||||
possessive = core_pattern.replace(r"\s+", r"\s++").replace(r"\s*", r"\s*+")
|
||||
# An optional leading " and" branch must start at a whitespace boundary;
|
||||
# otherwise search retries the whole possessive run at every suffix.
|
||||
possessive = possessive.replace(r"|\s++and", r"|(?<!\s)\s++and")
|
||||
return re.search(possessive + r"$", text[:end], re.I)
|
||||
|
||||
|
||||
def _strip_terminal_but(text: str) -> str:
|
||||
end = len(text)
|
||||
while end and text[end - 1].isspace():
|
||||
end -= 1
|
||||
if end < 3 or text[end - 3:end].casefold() != "but":
|
||||
return text
|
||||
start = end - 3
|
||||
if start == 0 or not text[start - 1].isspace():
|
||||
return text
|
||||
while start and text[start - 1].isspace():
|
||||
start -= 1
|
||||
return text[:start]
|
||||
|
||||
|
||||
def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||
"""Strip only whole, known presentation/safety suffixes, never actions."""
|
||||
text = _normalize_request_lead(message)
|
||||
@@ -2373,11 +2224,11 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||
if keep_few_suffix:
|
||||
maximum = 3
|
||||
text = text[:keep_few_suffix.start()].strip()
|
||||
few_suffix = _terminal_clause_match(
|
||||
text,
|
||||
few_suffix = re.search(
|
||||
r"[,.;?]\s*(?:(?:only|just)\s+)?(?:(?:list|show)\s+(?:me\s+)?)?a\s+few"
|
||||
r"(?:\s+(?:task\s+)?(?:names?|items?|results?|entries?))?"
|
||||
r"(?:\s+and\s+(?:whether|if)\s+[^.;\n]+)?",
|
||||
r"(?:\s+and\s+(?:whether|if)\s+[^.;\n]+)?[.!?]*\s*$",
|
||||
text, re.I,
|
||||
)
|
||||
if few_suffix:
|
||||
maximum = 3
|
||||
@@ -2389,43 +2240,42 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||
text,
|
||||
flags=re.I,
|
||||
).strip()
|
||||
terminal_read_only = _terminal_clause_match(
|
||||
text,
|
||||
text = re.sub(
|
||||
r"[.;]\s*read[- ]only(?:\s+(?:please|pls|plz))?\s*,?\s*"
|
||||
r"(?:(?:and\s+)?(?:do\s+not|don['’]?t|dont)\s+"
|
||||
r"(?:change|edit|modify)(?:\s+or\s+send)?\s+(?:anything|data))?",
|
||||
)
|
||||
if terminal_read_only:
|
||||
text = text[:terminal_read_only.start()].strip()
|
||||
# Explanatory/safety tails do not alter a preceding exact read request.
|
||||
safety_tail = _terminal_clause_match(
|
||||
r"(?:change|edit|modify)(?:\s+or\s+send)?\s+(?:anything|data))?"
|
||||
r"[.!?]*\s*$",
|
||||
"",
|
||||
text,
|
||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?(?:do\s+not|don['’]?t|dont)\s+"
|
||||
r"(?:touch|change|edit|modify)(?:\s+(?:anything|data|them))?(?:\s+yet)?",
|
||||
)
|
||||
if safety_tail:
|
||||
text = text[:safety_tail.start()].strip()
|
||||
flags=re.I,
|
||||
).strip()
|
||||
# Explanatory/safety tails do not alter a preceding exact read request.
|
||||
text = re.sub(
|
||||
r"[.!?]\s*+read[- ]only(?:\s++(?:please|pls|plz))?\s*+(?:,\s*+)?"
|
||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?(?:do\s+not|don['’]?t|dont)\s+"
|
||||
r"(?:touch|change|edit|modify)(?:\s+(?:anything|data|them))?(?:\s+yet)?[.!?]*\s*$",
|
||||
"", text, flags=re.I,
|
||||
).strip()
|
||||
text = re.sub(
|
||||
r"[.!?]\s*read[- ]only(?:\s+(?:please|pls|plz))?\s*,?\s*"
|
||||
r"(?:do\s+not|don['’]?t|dont)\s+(?:change|edit|modify)\s+"
|
||||
r"(?:or\s+send\s+)?anything[.!?]*\s*$",
|
||||
"", text, flags=re.I,
|
||||
).strip()
|
||||
for terminal_core in (
|
||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+changes?",
|
||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+edits?",
|
||||
):
|
||||
terminal_match = _terminal_clause_match(text, terminal_core)
|
||||
if terminal_match:
|
||||
text = text[:terminal_match.start()].strip()
|
||||
text = re.sub(
|
||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+changes?[.!?]*\s*$",
|
||||
"", text, flags=re.I,
|
||||
).strip()
|
||||
text = re.sub(
|
||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+edits?[.!?]*\s*$",
|
||||
"", text, flags=re.I,
|
||||
).strip()
|
||||
text = re.sub(
|
||||
r"[,;]\s*no\s+edits?[.!?]*\s*$", "", text, flags=re.I,
|
||||
).strip()
|
||||
terminal_no_write = _terminal_clause_match(
|
||||
text, r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+writes?"
|
||||
)
|
||||
if terminal_no_write:
|
||||
text = text[:terminal_no_write.start()].strip()
|
||||
text = re.sub(
|
||||
r"(?:(?:[,;]\s*(?:and\s+)?|\s+and\s+))?no\s+writes?[.!?]*\s*$",
|
||||
"", text, flags=re.I,
|
||||
).strip()
|
||||
text = re.sub(
|
||||
r"[.!?]\s*(?:i['’]?m|i\s+am)\s+(?:just\s+)?checking\b[^\n]*$",
|
||||
"", text, flags=re.I,
|
||||
@@ -2443,11 +2293,12 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||
text,
|
||||
flags=re.I,
|
||||
).strip()
|
||||
short_lines = _terminal_clause_match(
|
||||
text, r"[,;]\s*(?:keep\s+(?:them|it)\s+)?short\s+lines?\s*,?"
|
||||
)
|
||||
if short_lines:
|
||||
text = text[:short_lines.start()].strip()
|
||||
text = re.sub(
|
||||
r"[,;]\s*(?:keep\s+(?:them|it)\s+)?short\s+lines?\s*,?[.!?]*\s*$",
|
||||
"",
|
||||
text,
|
||||
flags=re.I,
|
||||
).strip()
|
||||
text = re.sub(
|
||||
r"[,.;]\s*keep\s+(?:the\s+answer|it|them)\s+short[.!?]*\s*$",
|
||||
"",
|
||||
@@ -2472,11 +2323,11 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||
maximum = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
||||
text = text[:approximate_limit.start()].strip()
|
||||
natural_limit = re.search(
|
||||
r"(?:[,.;?]|[—–-]|(?<!\s)\s++but\s++|(?<!\s)\s++)\s*+(?:"
|
||||
r"(?:i\s++)?only\s++(?:need|want|show(?:\s++me)?)?\s*+(?:(?:the\s++)?first\s++)?"
|
||||
r"|just\s++(?:(?:the\s++)?first\s++)?|(?:show\s++me\s++)?like\s++|no\s++more\s++than\s++"
|
||||
r"|cap(?:\s++(?:it|them|the\s++(?:answer|list)))?\s++at\s++"
|
||||
r"|(?:maybe\s++)?(?:first|same)\s++)"
|
||||
r"(?:[,.;?]|[—–-]|\s+but\s+|\s+)\s*(?:"
|
||||
r"(?:i\s+)?only\s+(?:need|want|show(?:\s+me)?)?\s*(?:(?:the\s+)?first\s+)?"
|
||||
r"|just\s+(?:(?:the\s+)?first\s+)?|(?:show\s+me\s+)?like\s+|no\s+more\s+than\s+"
|
||||
r"|cap(?:\s+(?:it|them|the\s+(?:answer|list)))?\s+at\s+"
|
||||
r"|(?:maybe\s+)?(?:first|same)\s+)"
|
||||
r"(" + _READ_COUNT + r")"
|
||||
r"(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?|bits?|things?))?"
|
||||
r"(?:\s*(?:and|\+)\s+(?:their\s+)?(?:status(?:es)?|states?))?"
|
||||
@@ -2514,7 +2365,7 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||
maximum = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
||||
text = text[:compact_limit.start()].strip()
|
||||
conversational_limit = re.search(
|
||||
r"(?:[,.;?]\s*|(?<!\s)\s++)(?:maybe\s+)?(?:keep\s+it\s+to\s+|stick\s+to\s+|(?:first|top)\s+)"
|
||||
r"(?:[,.;?]\s*|\s+)(?:maybe\s+)?(?:keep\s+it\s+to\s+|stick\s+to\s+|(?:first|top)\s+)"
|
||||
r"(" + _READ_COUNT + r")(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?))?"
|
||||
r"[.!?]*\s*$",
|
||||
text, re.I,
|
||||
@@ -2524,7 +2375,7 @@ def _read_request_and_limit(message: str) -> tuple[str, int | None]:
|
||||
value = int(raw) if raw.isdecimal() else _READ_COUNT_WORDS[raw.lower()]
|
||||
maximum = value if maximum is None else min(maximum, value)
|
||||
text = text[:conversational_limit.start()].rstrip(' ,.;?')
|
||||
text = _strip_terminal_but(text)
|
||||
text = re.sub(r"\s+but\s*$", "", text, flags=re.I)
|
||||
need_limit = re.search(
|
||||
r"[.!?]\s*(?:i\s+)?only\s+need\s+(" + _READ_COUNT + r")"
|
||||
r"(?:\s+(?:short\s+)?(?:titles?|items?|results?|entries?|names?|ones?))?"
|
||||
@@ -4149,13 +4000,21 @@ def _families_for_tool(tool: str) -> frozenset[str]:
|
||||
if bare in {"list_sessions", "manage_session", "create_session", "send_to_session",
|
||||
"chat_with_model", "pipeline"}:
|
||||
return frozenset({"sessions"})
|
||||
return frozenset(family for family, tools in FAMILY_TOOLS.items() if bare in tools)
|
||||
# The photo_editor family is context-only: it is only ever selected by an
|
||||
# explicit editor request at the route, never inferred from message text.
|
||||
# Overlapping tools such as generate_image must still resolve to their real
|
||||
# product family, so exclude photo_editor rather than short-circuiting.
|
||||
return frozenset(
|
||||
family
|
||||
for family, tools in FAMILY_TOOLS.items()
|
||||
if bare in tools and family != "photo_editor"
|
||||
)
|
||||
|
||||
|
||||
def _clause_capabilities(text: str) -> set[str]:
|
||||
# A prohibition constrains authority; it must never grant the family named
|
||||
# only as the forbidden side effect (for example, "do not create a file").
|
||||
if _is_pure_action_prohibition(text):
|
||||
if _PURE_ACTION_PROHIBITION.fullmatch(text):
|
||||
return set()
|
||||
container_tool = creation_container_tool(text)
|
||||
if container_tool:
|
||||
@@ -4299,6 +4158,11 @@ def _clause_capabilities(text: str) -> set[str]:
|
||||
if intent.reason == "terse calendar follow-up action":
|
||||
return set()
|
||||
words = {f for f, pattern in _FAMILY_WORDS.items() if re.search(pattern, text, re.I)}
|
||||
if "tasks" in words and not explicit_task_management(text):
|
||||
# A bare mention of "task"/"todo"/"automation" in a project description
|
||||
# is content, not a request to create or read a scheduled task. Only an
|
||||
# explicit task-management request may select the tasks family.
|
||||
words.discard("tasks")
|
||||
personal_stores = words & {
|
||||
"calendar", "notes", "tasks", "skills", "memory", "documents", "email",
|
||||
}
|
||||
@@ -4406,6 +4270,14 @@ def _clause_capabilities(text: str) -> set[str]:
|
||||
mapped = "tasks"
|
||||
if mapped in FAMILY_TOOLS:
|
||||
return {mapped}
|
||||
if "shell_files" in words and re.search(
|
||||
r"\b(?:bash|shell|terminal|zsh|command\s+line|cmd)\b", text, re.I,
|
||||
):
|
||||
# An explicitly named shell is a request to use the shell even when the
|
||||
# rest of the message is a bare or typo'd command ("Bash commabd Ls la").
|
||||
# Grant the shell family so the always-offered bash tool is not denied
|
||||
# by request authority (offer/admission alignment).
|
||||
return {"shell_files"}
|
||||
# Families absent from action_intents still need a generic action gate;
|
||||
# merely discussing a domain must not offer its mutation tools.
|
||||
return words if _has_action_signal(text) or personal_lookup else set()
|
||||
@@ -4816,7 +4688,12 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum
|
||||
raw_text,
|
||||
re.I,
|
||||
)
|
||||
and _mentions_workspace_output(raw_text)
|
||||
and re.search(
|
||||
r"(?:file://)?/workspace/(?!input/)[^\s`\"']+\."
|
||||
r"(?:csv|html?|json|md|svg|txt|avif|bmp|gif|jpe?g|png|webp|pdf|mp4|webm)\b",
|
||||
raw_text,
|
||||
re.I,
|
||||
)
|
||||
):
|
||||
families.add("shell_files")
|
||||
if re.search(
|
||||
@@ -5275,7 +5152,7 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum
|
||||
and re.search(r"\b(?:ones?|top|apps?|services?|providers?)\b", text, re.I)
|
||||
and re.search(r"\b(?:price|cheap|under|dimensions?|quote|trustworthy|app)\b", text, re.I)
|
||||
)
|
||||
or _mentions_under_budget(text)
|
||||
or re.search(r"\bunder\s+[¥$€£]?\s*\d+(?:[.,]\d+)?(?:\s*yen)?\b", text, re.I)
|
||||
):
|
||||
return frozenset({"search_browser"})
|
||||
if recent_family == ("search_browser",) and re.fullmatch(
|
||||
@@ -6212,8 +6089,8 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum
|
||||
elif (recent == ("search_browser",) and families == {"cookbook_admin"}
|
||||
and re.match(r"^\s*" + _REQUEST_PREFIX + r"(?:search|find)\s+(?:those|them|these)\b", text, re.I)):
|
||||
families = {"search_browser"}
|
||||
if not families and (recall := _warm_recall_parts(text)):
|
||||
target = recall[0].lower()
|
||||
if not families and (recall := _WARM_RECALL.fullmatch(text)):
|
||||
target = recall["target"].lower()
|
||||
family = {
|
||||
"email": "email", "emails": "email", "inbox": "email",
|
||||
"note": "notes", "notes": "notes", "task": "tasks", "tasks": "tasks",
|
||||
@@ -6225,8 +6102,8 @@ def requested_capabilities(message: str, history: Iterable = (), *, active_docum
|
||||
}[target]
|
||||
if family in recently_executed_families(history):
|
||||
families.add(family)
|
||||
if not families and (recall := _warm_recall_parts(text, with_followup=True)):
|
||||
target = recall[0].lower()
|
||||
if not families and (recall := _WARM_RECALL_WITH_FOLLOWUP.fullmatch(text)):
|
||||
target = recall["target"].lower()
|
||||
family = {
|
||||
"email": "email", "emails": "email", "inbox": "email",
|
||||
"note": "notes", "notes": "notes", "task": "tasks", "tasks": "tasks",
|
||||
@@ -6325,7 +6202,7 @@ def resolve_full_inventory_contract(*, schemas: Iterable[dict], policy: ToolPoli
|
||||
"""Experimental trained inventory: permissions filter offers; model chooses actions."""
|
||||
families = frozenset({"calendar", "notes", "tasks", "skills", "memory", "documents",
|
||||
"email", "search_browser", "shell_files", "cookbook_admin",
|
||||
"image_editing", "image_generation"})
|
||||
"image_editing", "image_generation", "photo_editor"})
|
||||
# ``ui_control`` is the executable bridge for explicit client-interface
|
||||
# requests (for example, opening the gallery). It is not one of the ten
|
||||
# persisted-data families, but omitting it here makes the full-inventory
|
||||
|
||||
+2
-55
@@ -12,7 +12,7 @@ break the primary use case. What it *always* rejects:
|
||||
- a non-HTTP(S) scheme (``file://``, ``gopher://``, ``ftp://`` …), and
|
||||
- the link-local range (``169.254.0.0/16`` / ``fe80::/10``), i.e. the cloud
|
||||
instance-metadata SSRF credential-exfil vector — nobody serves embeddings
|
||||
there, plus multicast / non-loopback reserved / unspecified addresses.
|
||||
there — plus multicast / reserved / unspecified addresses.
|
||||
|
||||
For exposed multi-tenant deployments, set ``EMBEDDING_BLOCK_PRIVATE_IPS=true`` to
|
||||
additionally reject all private and loopback targets (full SSRF lockdown).
|
||||
@@ -94,15 +94,11 @@ def _classify(ip: ipaddress._BaseAddress, *, block_private: bool) -> Optional[st
|
||||
return None
|
||||
if ip.is_link_local:
|
||||
return f"link-local address blocked (SSRF metadata risk): {ip}"
|
||||
# IPv6 loopback is also reserved; its policy must match IPv4 loopback.
|
||||
if ip.is_loopback:
|
||||
if block_private:
|
||||
return f"private/shared/loopback address blocked: {ip}"
|
||||
return None
|
||||
if ip.is_multicast or ip.is_reserved or ip.is_unspecified:
|
||||
return f"disallowed address: {ip}"
|
||||
if block_private and (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or (isinstance(ip, ipaddress.IPv4Address) and ip in _SHARED_ADDRESS_SPACE_V4)
|
||||
):
|
||||
return f"private/shared/loopback address blocked: {ip}"
|
||||
@@ -158,52 +154,3 @@ def check_outbound_url(
|
||||
if not saw_ip:
|
||||
return False, "host does not resolve to an IP"
|
||||
return True, "ok"
|
||||
|
||||
|
||||
|
||||
class OutboundAddressBlocked(PermissionError):
|
||||
"""A non-HTTP outbound host resolves into a disallowed address range."""
|
||||
|
||||
|
||||
def connect_outbound_tcp(
|
||||
host: str,
|
||||
port: int,
|
||||
*,
|
||||
timeout=socket._GLOBAL_DEFAULT_TIMEOUT,
|
||||
block_private: bool = False,
|
||||
source_address=None,
|
||||
resolver: Optional[Callable[..., list]] = None,
|
||||
) -> socket.socket:
|
||||
"""Open a TCP connection to *host* under the outbound address policy.
|
||||
|
||||
For raw TCP clients (IMAP/SMTP). The host is resolved exactly once, every
|
||||
resolved address is judged with the same policy as check_outbound_url, and
|
||||
the socket connects only to those already-judged addresses. A second
|
||||
lookup at connect time (what socket.create_connection(host) does) would let
|
||||
a rebinding name pass the check with a public answer and then connect to
|
||||
metadata/private space. TLS callers keep wrapping the returned socket with
|
||||
``server_hostname=host``, so SNI and certificate checks are unchanged.
|
||||
"""
|
||||
resolve = resolver or socket.getaddrinfo
|
||||
infos = resolve(host, port, 0, socket.SOCK_STREAM)
|
||||
for _family, _type, _proto, _canon, sockaddr in infos:
|
||||
ip = ipaddress.ip_address(str(sockaddr[0]).split("%")[0])
|
||||
reason = _classify(ip, block_private=block_private)
|
||||
if reason:
|
||||
raise OutboundAddressBlocked(reason)
|
||||
last_error: Optional[OSError] = None
|
||||
for family, socktype, proto, _canon, sockaddr in infos:
|
||||
sock = socket.socket(family, socktype, proto)
|
||||
try:
|
||||
if timeout is not socket._GLOBAL_DEFAULT_TIMEOUT: # same contract as create_connection
|
||||
sock.settimeout(timeout)
|
||||
if source_address:
|
||||
sock.bind(source_address)
|
||||
sock.connect(sockaddr)
|
||||
return sock
|
||||
except OSError as exc:
|
||||
last_error = exc
|
||||
sock.close()
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise socket.gaierror(socket.EAI_NONAME, f"{host} did not resolve to an address")
|
||||
|
||||
@@ -61,3 +61,4 @@ def backend_workspace_path(path: str | None) -> str:
|
||||
return container_root
|
||||
return str(PurePosixPath(container_root) / rel)
|
||||
return expanded
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
@@ -13,6 +13,7 @@
|
||||
in email bodies — was wrapping random digits in <a href="tel:..."> with
|
||||
browser-default styling that didn't match the Odysseus theme. -->
|
||||
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no">
|
||||
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
window._odysseusLoadTime = Date.now();
|
||||
(function(){
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<title>Odysseus — Login</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M16 4L16 22L6 22Z' fill='%23e06c75'/%3E%3Cpath d='M16 8L16 22L24 22Z' fill='%23e06c75' opacity='0.6'/%3E%3Cpath d='M4 24Q10 20 16 24Q22 28 28 24' stroke='%23e06c75' stroke-width='2.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E">
|
||||
<link rel="manifest" href="static/manifest.json">
|
||||
<link rel="apple-touch-icon" href="static/icons/icon-192.png">
|
||||
<script nonce="{{CSP_NONCE}}">
|
||||
(function(){
|
||||
function computeAppBasePath() {
|
||||
|
||||
@@ -7,5 +7,10 @@
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#282c34",
|
||||
"theme_color": "#282c34"
|
||||
"theme_color": "#282c34",
|
||||
"icons": [
|
||||
{ "src": "icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" },
|
||||
{ "src": "icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
|
||||
+5
-1
@@ -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-v657-publication-plan-b';
|
||||
const CACHE_NAME = 'odysseus-v658-publication-assets-restore';
|
||||
|
||||
// KaTeX resolves these from its own stylesheet, so caching the CSS without them
|
||||
// gives offline math fallback glyphs instead of proper typesetting.
|
||||
@@ -134,6 +134,10 @@ const PRECACHE = [
|
||||
'/static/lib/katex/katex.min.js',
|
||||
'/static/lib/katex/katex.min.css',
|
||||
...KATEX_FONTS,
|
||||
'/static/manifest.json',
|
||||
'/static/icons/icon-192.png',
|
||||
'/static/icons/icon-512.png',
|
||||
'/static/icons/icon-maskable-512.png',
|
||||
];
|
||||
|
||||
// Lazily-imported panel modules (js/panels.js). Not in index.html by design;
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
# Wave 6 test-isolation foundation
|
||||
|
||||
## Provenance and boundary
|
||||
|
||||
- Worktree: `/home/alteixeira20/Projects/Odysseus/odysseus-wave6-test-isolation`.
|
||||
- Starting branch: `wave6/test-isolation-foundation`.
|
||||
- Starting HEAD: `b1666951faf8285054e1ca90f11533b0fb53fb57`.
|
||||
- Starting TREE: `a3dee24e3c56c22e1d3f9805f409bf16b086c3d4`.
|
||||
- `git status --short --branch`: `## wave6/test-isolation-foundation` only.
|
||||
- `git status --porcelain`: empty. Starting worktree clean.
|
||||
- No rebase, merge, push, PR edit, or access to the Wave 4 worktree.
|
||||
- All changes are test code, test helpers, or this test validation report.
|
||||
No production code changed; no STOP-level exception was needed.
|
||||
|
||||
## Contamination map
|
||||
|
||||
| Source | Mutation / binding | Previous cleanup | Escaping state / effect |
|
||||
| --- | --- | --- | --- |
|
||||
| Both tests in `tests/test_truncate_message_count_regression.py`, through `_make_manager` | Creates an unowned `mkstemp` DB; directly sets `os.environ["DATABASE_URL"]`; reloads `core.database` then `core.session_manager` | Only the assertion's ORM session is closed | Environment, engine, factory, declarative Base, ORM classes, manager globals, and registered engine listeners survive; database files are never deleted |
|
||||
| Collection of `tests/test_manage_tasks_owner_scope.py` | Creates a module-lifetime temporary DB and assigns `_TS` permanently to `core.database.SessionLocal` | Some individual rows deleted; ORM sessions closed | The collection-time factory assignment leaks to unrelated tests; truncation reload replaces it, so production handlers and `_seed` / `_get` use different databases |
|
||||
| `test_checkin_digest_owner_scope.py`, `test_gallery_owner_filter_single_user.py`, `test_document_session_owner_scope.py` | Create module-lifetime DB files / engines and retain local factories | Some rows deleted / route factory restored / ORM sessions closed | Owned files and engines lack teardown; retained imported ORM identities can disagree with reloaded module globals |
|
||||
| `tests/conftest.py` bootstrap | `DATABASE_URL` was set only if absent before importing `core.database` | No environment restoration | An inherited developer URL could be opened and initialized during collection |
|
||||
|
||||
`importlib.reload` updates the existing module dictionary in place; it does
|
||||
not change the `sys.modules` module object's identity. Previously imported
|
||||
symbols nevertheless keep their old class/factory identities. In particular,
|
||||
`src.database` re-exports old objects, while reloaded `core.session_manager`
|
||||
rebinds its `DbSession`, `DbChatMessage`, `DbDocument`, `SessionLocal`, and
|
||||
`SessionManager`. Old functions still resolve globals through those changed
|
||||
module dictionaries. Each database reload also registers a new SQLite connect
|
||||
listener on SQLAlchemy's Engine class.
|
||||
|
||||
The current failures are principally in `test_manage_tasks_owner_scope.py`:
|
||||
`_seed` writes through its retained `_TS`, but `do_manage_tasks` imports the
|
||||
now-replaced factory from `core.database` at call time. Ownerless / cross-owner
|
||||
mutations incorrectly observe "task not found"; successful mutation/list/create
|
||||
witnesses likewise read or write the wrong test database. Four invalid-date
|
||||
cases still pass, so the observed contamination count is 16 rather than all
|
||||
20 task-owner cases.
|
||||
|
||||
`src.settings` has TTL caches, but those caches do not resolve DATABASE_URL.
|
||||
The contaminating helper neither patches nor reloads settings. The database
|
||||
URL is read directly from the environment at database import/reload time;
|
||||
settings cache invalidation is not the correction for this leak.
|
||||
|
||||
## Baseline reproduction before editing
|
||||
|
||||
All baseline commands explicitly selected in-memory bootstrap storage. Python
|
||||
with installed pytest/SQLAlchemy was
|
||||
`/tmp/pr40-final-validation-venv/bin/python` (pytest 9.1.1, SQLAlchemy 2.1.1).
|
||||
|
||||
```sh
|
||||
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py tests/test_truncate_message_count_regression.py
|
||||
# 30 passed, 9 warnings in 2.48s
|
||||
|
||||
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_truncate_message_count_regression.py tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py
|
||||
# 16 failed, 14 passed, 9 warnings in 2.40s
|
||||
|
||||
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rx --runxfail tests/test_runtime_behavior_regressions.py -k negative_web
|
||||
# 2 failed (IndexError), 1 passed, 13 deselected, 1 warning in 1.61s
|
||||
```
|
||||
|
||||
The same 30 database tests shuffled with `random.Random(42)` before editing
|
||||
reported **9 failed, 21 passed**, 9 warnings in 2.37s. Exact command:
|
||||
|
||||
```sh
|
||||
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:42 before-seed42 tests/test_manage_tasks_owner_scope.py tests/test_checkin_digest_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_document_session_owner_scope.py tests/test_truncate_message_count_regression.py
|
||||
```
|
||||
|
||||
## Isolation correction and regression evidence
|
||||
|
||||
- `tests/helpers/database.py` owns a fresh TemporaryDirectory, file-backed
|
||||
SQLite engine with NullPool, real schema, and factory for each invocation.
|
||||
Teardown disposes the engine and removes the directory including sidecars.
|
||||
- The truncation fixture temporarily patches only resource bindings in
|
||||
`core.database`, `src.database`, and `core.session_manager`, with a matching
|
||||
temporary environment URL. Patches are undone before resource disposal.
|
||||
No module reload, module eviction, new ORM class, or listener registration.
|
||||
- Four owner modules now own a disposable database per test rather than at
|
||||
collection. Only the task-owner module patches the canonical factory; that
|
||||
patch is scoped to the test and restored during teardown. The other modules
|
||||
retain their existing handler/route wiring and ownership assertions.
|
||||
- The existing pytest bootstrap always chooses in-memory storage before
|
||||
production database imports and registers environment restoration with the
|
||||
pytest configuration lifecycle. No new process-wide autouse cleanup fixture.
|
||||
- `tests/test_database_test_isolation.py` checks real persistent truncation,
|
||||
exact class/factory restoration, file removal, clean subsequent database
|
||||
state, and exception teardown. Child pytest runs execute the actual original
|
||||
witness entry points in both directions and check process-visible bindings
|
||||
after collection and after every test. An inherited developer-DB sentinel
|
||||
remains byte-identical, with no database sidecars created, and the inherited
|
||||
environment URL is restored after pytest exits.
|
||||
|
||||
Mutation checks temporarily restored one original file at a time, ran
|
||||
`test_actual_tests_restore_process_state_and_ignore_inherited_database[True]`,
|
||||
and restored the corrected file in `finally`. Both mutations made the new
|
||||
regression fail (pytest exit 1): original truncation was caught as
|
||||
"test leaked database or module state", original task-owner collection as
|
||||
"collection changed database bindings". No mutation was retained.
|
||||
|
||||
## Historical negative-web witnesses and production blocker
|
||||
|
||||
Both parameter cases in
|
||||
`test_negative_web_wording_withholds_the_web_tools_unhandled` intended to prove
|
||||
that inferred negative-web wording can receive a memory-only answer without
|
||||
`web_search` or `web_fetch` being offered to the model:
|
||||
|
||||
1. `Summarise what you already know. Do not search the web.`
|
||||
2. `No web search please, just tell me what you know about Python decorators.`
|
||||
|
||||
Both previously indexed an empty fake-model call list and xfailed on unrelated
|
||||
`IndexError`. The fake also emitted provider-shaped `choices` JSON at a seam
|
||||
that now consumes normalized `{"delta": ...}` SSE.
|
||||
|
||||
The repaired witnesses drive the unmodified public `stream_agent_loop`, mock
|
||||
only model output and existing infrastructure edges, capture the actual SSE,
|
||||
and require one model call, withheld web schemas, and delivered fake answer.
|
||||
The existing passing "don't search online" control and positive search
|
||||
control remain. The fake now emits normalized SSE.
|
||||
|
||||
The two historical xfails remain **strict**, with
|
||||
`raises=_MemoryOnlyTurnBlocked`. That exception is emitted only when the real
|
||||
loop makes zero model calls and returns the exact known disabled-web response.
|
||||
IndexError, arbitrary assertions, or other exceptions no longer qualify.
|
||||
|
||||
This is a genuine production defect outside the lane: negative-web detection
|
||||
is true for both phrasings, but `_classify_agent_request` returns `{'web'}` and
|
||||
`_web_search_unavailable_for_turn` returns true after the negative has disabled
|
||||
web tools. The loop short-circuits the requested memory-only response with:
|
||||
|
||||
> Web access is disabled for this turn. Enable web search and resend the request.
|
||||
|
||||
The passing "Answer from memory only, don't search online." control has
|
||||
`{'memory', 'web'}` intent domains and does not short-circuit. A minimized
|
||||
reproduction using those three production functions requires neither a model
|
||||
nor a mock. Relevant production locations (intentionally untouched):
|
||||
`src/agent_loop.py` negative detector near line 864, classifier near 10371,
|
||||
negative-tool disabling near 20735, unavailable calculation near 22233, and
|
||||
short-circuit response near 25101.
|
||||
|
||||
```sh
|
||||
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider --runxfail tests/test_runtime_behavior_regressions.py -k negative_web_wording_withholds_the_web_tools_unhandled
|
||||
# 2 failed with _MemoryOnlyTurnBlocked, 14 deselected, 1 warning in 0.75s
|
||||
```
|
||||
|
||||
No production repair was attempted. Runtime intent/availability behavior,
|
||||
authority, completion/evidence, and Wave 4 effects files remain untouched.
|
||||
This blocker prevents a READY release-foundation verdict despite passing test
|
||||
isolation validation.
|
||||
|
||||
## Final order validation
|
||||
|
||||
The focused manifest has 50 cases: the five baseline database modules plus
|
||||
`test_database_test_isolation.py` and `test_runtime_behavior_regressions.py`.
|
||||
Normal collection order is alphabetical by the seven explicitly selected files.
|
||||
Reversed and seeded runs reorder **individual collected test cases**, including
|
||||
parameterized cases. Both contamination directions also vary file collection
|
||||
order. Every execution manifest is retained as JSON in the evidence directory.
|
||||
|
||||
| Run | Result |
|
||||
| --- | --- |
|
||||
| Normal | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 9.01s |
|
||||
| Reverse of all collected cases | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.47s |
|
||||
| Truncation module first, owner witnesses afterward | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.00s |
|
||||
| All four owner modules first, truncation afterward | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.98s |
|
||||
| Seed 1 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 9.01s |
|
||||
| Seed 42 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.98s |
|
||||
| Seed 2026 | 48 passed, 2 xfailed, 0 skipped, 11 warnings in 8.97s |
|
||||
|
||||
Exact executed commands:
|
||||
|
||||
```sh
|
||||
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-normal tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
|
||||
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py reverse after-reverse tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
|
||||
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-truncation-first tests/test_truncate_message_count_regression.py tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py
|
||||
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-owners-first tests/test_checkin_digest_owner_scope.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_truncate_message_count_regression.py tests/test_database_test_isolation.py tests/test_runtime_behavior_regressions.py
|
||||
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:1 after-seed1 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
|
||||
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:42 after-seed42 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
|
||||
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py seed:2026 after-seed2026 tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
|
||||
```
|
||||
|
||||
The order driver is test-session-local; it is not installed into conftest or
|
||||
production and never changes the normal suite's ordering. Its exact source
|
||||
(for recreating `/tmp/odysseus-wave6-isolation-evidence/run_order.py`) is:
|
||||
|
||||
```python
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import random
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
out = Path('/tmp/odysseus-wave6-isolation-evidence')
|
||||
mode, label, *modules = sys.argv[1:]
|
||||
class Order:
|
||||
@pytest.hookimpl(trylast=True)
|
||||
def pytest_collection_modifyitems(self, items):
|
||||
if mode == 'reverse':
|
||||
items.reverse()
|
||||
elif mode.startswith('seed:'):
|
||||
random.Random(int(mode.split(':')[1])).shuffle(items)
|
||||
(out / f'{label}-manifest.json').write_text(json.dumps([i.nodeid for i in items], indent=2) + '\n')
|
||||
os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
|
||||
args = ['-q', '-p', 'no:cacheprovider', '-rsx', *modules]
|
||||
(out / f'{label}-command.json').write_text(json.dumps({'argv':sys.argv,'pytest_args':args,'DATABASE_URL':os.environ['DATABASE_URL']},indent=2) + '\n')
|
||||
raise SystemExit(pytest.main(args, plugins=[Order()]))
|
||||
```
|
||||
|
||||
## Adjacent and complete validation
|
||||
|
||||
The first complete run finished with **44 failed, 12509 passed, 62 skipped,
|
||||
2 xfailed**, 184 warnings and 6 passing subtests in 391.85s. Its failure
|
||||
inventory was 42 missing-Playwright-package failures and two generated
|
||||
configuration-reference freshness failures. The local worktree had not had
|
||||
`npm ci` run; the generator includes test-source line references, and the
|
||||
bootstrap edit moved the existing references by one line.
|
||||
|
||||
`npm ci --ignore-scripts` installed the four already-locked packages; package
|
||||
and lock files were unchanged. Cached Chromium launched successfully
|
||||
(version 151.0.7922.34). A bootstrap comment now explains why isolation must
|
||||
precede collection and preserves the existing referenced line locations,
|
||||
without editing the generated website or the generator. This follow-up is
|
||||
commit `f67d0388fd805867a0920dbffd4da056b1361e38`
|
||||
(`fix(tests): preserve bootstrap reference locations`).
|
||||
|
||||
```sh
|
||||
npm ci --ignore-scripts
|
||||
# exit 0; 4 packages installed, lockfile and package policy unchanged
|
||||
|
||||
python scripts/generate_env_reference.py --check
|
||||
# exit 0; website/configuration-reference.md is up to date (112 variables)
|
||||
|
||||
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_document_module_api.py tests/test_env_reference.py
|
||||
# 16 passed, 1 warning in 17.00s
|
||||
```
|
||||
|
||||
The first full run and the 44-case focused rerun are retained separately from
|
||||
the final complete run. The 44-case rerun reported **1 failed, 43 passed**
|
||||
in 73.61s: the existing font/color browser witness observed its font-size
|
||||
icon still showing `13` after its text had changed to size 5 (expected `24`).
|
||||
An isolated reproduction of that unchanged test passed in 2.13s. This is an
|
||||
observed intermittent browser UI witness; no database failure was involved,
|
||||
and a production root cause has not been established. The test and editor
|
||||
implementation remain unchanged, with both observations retained rather
|
||||
than suppressing the failed run. No failure was hidden by skipping or deleting a test.
|
||||
|
||||
|
||||
```sh
|
||||
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rsx tests/test_session_manager.py tests/test_session_manager_cleanup.py tests/test_session_manager_persist_guard.py tests/test_session_mutation_lookup.py tests/test_session_discovery_message_count.py tests/test_history_order_by_timestamp_regression.py tests/test_history_display_model_hydration.py tests/test_session_search.py tests/test_session_search_batch_fetch.py tests/test_session_list_owner_scope.py tests/test_session_owner_attribution.py tests/test_session_endpoint_owner_scope.py tests/test_session_concurrent.py tests/test_app_db_permissions.py tests/test_database_utcnow.py tests/test_tool_policy.py tests/test_turn_contract.py tests/test_chat_preprocess_tool_policy.py tests/test_chat_route_tool_policy.py tests/test_resend_message_nondestructive.py
|
||||
# 1262 passed, 0 skipped, 0 xfailed, 7 warnings in 40.56s
|
||||
|
||||
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider -rsx
|
||||
# 12556 passed, 59 skipped, 2 xfailed, 184 warnings, 6 subtests passed in 468.49s (0:07:48); exit 0
|
||||
|
||||
python -m compileall -q app.py core routes src services scripts tests
|
||||
# exit 0
|
||||
|
||||
git diff --check
|
||||
# exit 0, no output
|
||||
|
||||
git diff --check b1666951faf8285054e1ca90f11533b0fb53fb57
|
||||
# exit 0, no output
|
||||
|
||||
git diff --name-only --diff-filter=U
|
||||
# exit 0, no output
|
||||
|
||||
rg -n '^(<<<<<<< |=======$|>>>>>>> )' <each modified file>
|
||||
# exit 1, no matches
|
||||
```
|
||||
|
||||
No test was deleted, no skip was added, and no unrelated xfail was added. The
|
||||
same two historical parameter cases remain xfailed for the demonstrated
|
||||
production blocker, now restricted to that actual contract failure.
|
||||
|
||||
The isolation commit is
|
||||
`e2b847c84d27ce785565757e81ca5293896eaab3`
|
||||
(`fix(tests): isolate database and module state`). The final bounded commit
|
||||
contains the repaired web witnesses and this validation report. Final commit,
|
||||
HEAD, TREE and clean status are supplied in the accompanying final report to
|
||||
avoid placing a commit's own hash inside its committed contents.
|
||||
|
||||
Full logs, commands, and per-run manifests from this session are in
|
||||
`/tmp/odysseus-wave6-isolation-evidence/`.
|
||||
|
||||
The follow-up normal focused run after preserving bootstrap reference
|
||||
locations is recorded in `after-bootstrap-followup.log`. It reported **48 passed, 2 xfailed**, 11 warnings in 9.55s. Its exact command is:
|
||||
|
||||
```sh
|
||||
/tmp/pr40-final-validation-venv/bin/python /tmp/odysseus-wave6-isolation-evidence/run_order.py normal after-bootstrap-followup tests/test_checkin_digest_owner_scope.py tests/test_database_test_isolation.py tests/test_document_session_owner_scope.py tests/test_gallery_owner_filter_single_user.py tests/test_manage_tasks_owner_scope.py tests/test_runtime_behavior_regressions.py tests/test_truncate_message_count_regression.py
|
||||
```
|
||||
|
||||
The additional browser observation came from the 44-case manifest retained in
|
||||
`full-failures-manifest.json`; every exact node ID in that diagnostic command is
|
||||
retained in `full-failures-rerun-command.json`. The isolated diagnostic command:
|
||||
|
||||
```sh
|
||||
/tmp/pr40-final-validation-venv/bin/python -m pytest -q -p no:cacheprovider tests/test_document_rich_font_color_controls.py::test_numeric_font_size_and_custom_colors_work_on_desktop_and_mobile
|
||||
# 1 passed, 1 warning in 2.13s
|
||||
```
|
||||
|
||||
## Independent review disposition
|
||||
|
||||
A read-only review of isolation commit e2b847c8 raised two concerns, which
|
||||
were checked against the frozen source rather than accepted as proven leaks:
|
||||
|
||||
- `core.models._SESSION_MANAGER_INSTANCE`: `SessionManager.__init__` only
|
||||
initializes its owned cache and loads metadata; it does not call the global
|
||||
setter. `SessionManager.add_message` explicitly persists through its own
|
||||
manager instead of calling `Session.add_message` / the singleton. Neither
|
||||
truncation witness nor the helper invokes a singleton setter or API route
|
||||
setup. Thus the asserted singleton mutation is absent from this lane's real
|
||||
seam. Resetting that unrelated singleton would add cleanup without a proven
|
||||
owned mutation and could hide a genuine leak elsewhere.
|
||||
- Eager real-database import versus `clear_fake_database_modules`: the exact
|
||||
pre-import block already exists on frozen b1666951. The clearing helper
|
||||
intentionally leaves real modules with a string `__file__` intact and evicts
|
||||
replacement fake modules lacking it. The correction neither adds that
|
||||
pre-import nor changes fake-module identification. Its "real module" early
|
||||
return is intended behavior, not a newly introduced cleanup conflict.
|
||||
|
||||
Neither concern supplied a failing reproducer. Both were resolved by the
|
||||
existing source paths and the actual collection/teardown identity regression;
|
||||
no speculative global cleanup was introduced.
|
||||
|
||||
The minimized production reproduction command was:
|
||||
|
||||
```sh
|
||||
DATABASE_URL=sqlite:///:memory: /tmp/pr40-final-validation-venv/bin/python - <<'PYTHON'
|
||||
from src.agent_loop import _explicitly_avoids_web_lookup, _classify_agent_request, _web_search_unavailable_for_turn
|
||||
from src.tool_policy import WEB_TOOL_NAMES
|
||||
for text in (
|
||||
"Answer from memory only, don't search online.",
|
||||
'Summarise what you already know. Do not search the web.',
|
||||
'No web search please, just tell me what you know about Python decorators.',
|
||||
):
|
||||
intent = _classify_agent_request([{'role': 'user', 'content': text}], text)
|
||||
domains = set(intent['domains'])
|
||||
print(repr(text))
|
||||
print('negative_web:', _explicitly_avoids_web_lookup(text))
|
||||
print('intent_domains:', sorted(domains))
|
||||
print('web_disabled_short_circuit:', _web_search_unavailable_for_turn(domains, set(WEB_TOOL_NAMES), text, None, None))
|
||||
PYTHON
|
||||
```
|
||||
|
||||
Results: negative-web detection true for all three; intent domains respectively
|
||||
`['memory', 'web']`, `['web']`, `['web']`; short-circuit false, true, true.
|
||||
|
||||
## Final validation result and verdict
|
||||
|
||||
The final complete run in `full-pytest-final.log` exited 0:
|
||||
**12556 passed, 59 skipped, 2 xfailed**, 184 warnings and 6 passing subtests in
|
||||
468.49s. All 12617 collected cases are accounted for. The existing optional,
|
||||
platform-specific and opt-in skips remain; no skip marker was added. Installing
|
||||
the already-locked Node dependencies enabled three previously skipped cases,
|
||||
so the skip count decreased from 62 in the first run to 59. No tests were
|
||||
deleted and no unrelated xfail was introduced.
|
||||
|
||||
The font/color browser witness passed in the final complete run. Its failed
|
||||
44-case diagnostic and passing isolated result remain documented as an
|
||||
intermittent observation; no production root cause was established or fixed.
|
||||
|
||||
Database/environment/module isolation validation is green. Both historical
|
||||
negative-web cases remain narrowly expected failures for a reproduced genuine
|
||||
runtime defect outside the test-isolation boundary. These are integration
|
||||
blockers; production intent/short-circuit behavior must be corrected in an
|
||||
appropriate lane before removing their strict markers.
|
||||
|
||||
WAVE 6 TEST-ISOLATION FOUNDATION NOT READY
|
||||
@@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const width of [390, 900]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 844 }, acceptDownloads: true });
|
||||
await page.goto('http://127.0.0.1:7011/static/js/documentStats.js');
|
||||
await page.setContent('<link rel="stylesheet" href="/static/style.css"><div id="toast"></div><div id="chat-container"></div><div id="sidebar"></div>');
|
||||
await page.evaluate(async () => {
|
||||
const mod = await import('/static/js/document.js?mobile-export-regression=1');
|
||||
mod.init('/api');
|
||||
mod.injectFreshDoc({ id: 'export-test', title: 'Export test', language: 'markdown', version_count: 1, current_content: '# Mobile export\n\nActual document content.' });
|
||||
});
|
||||
await page.locator('#doc-footer-export-btn').waitFor({ state: 'visible' });
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('.doc-editor-pane').style.setProperty('z-index', '12000', 'important');
|
||||
});
|
||||
await page.locator('#doc-footer-export-btn').click();
|
||||
const menu = page.locator('#doc-export-menu');
|
||||
await menu.waitFor({ state: 'visible' });
|
||||
const bounds = await menu.boundingBox();
|
||||
assert(bounds.x >= 0 && bounds.y >= 0);
|
||||
assert(bounds.x + bounds.width <= width && bounds.y + bounds.height <= 844);
|
||||
assert(await menu.evaluate(el => Number(getComputedStyle(el).zIndex) > 12000));
|
||||
await page.screenshot({ path: `/tmp/document-export-${width}.png` });
|
||||
// A second click closes the menu, then reopening must still allow export.
|
||||
await page.locator('#doc-footer-export-btn').click();
|
||||
assert.equal(await menu.count(), 0);
|
||||
await page.locator('#doc-footer-export-btn').click();
|
||||
const pending = page.waitForEvent('download');
|
||||
await page.locator('#doc-export-menu button').filter({ hasText: /^Export Markdown$/ }).click();
|
||||
const download = await pending;
|
||||
assert(download.suggestedFilename().endsWith('.md'));
|
||||
assert.equal(await download.failure(), null);
|
||||
const stream = await download.createReadStream();
|
||||
let content = '';
|
||||
for await (const chunk of stream) content += chunk;
|
||||
assert.equal(content, '# Mobile export\n\nActual document content.');
|
||||
await page.close();
|
||||
console.log(`PASS ${width}px: visible menu, toggle, download contents`);
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { chromium } from 'playwright';
|
||||
|
||||
const source = readFileSync(new URL('../static/js/document.js', import.meta.url), 'utf8');
|
||||
const build = source.slice(source.indexOf(' function _buildAnnotation('), source.indexOf(' // Prompt user for an instruction'));
|
||||
const state = source.slice(source.indexOf(' let _pdfAnnotationDragging'), source.indexOf(' // Per-doc last-used line spacing'));
|
||||
const placement = source.slice(source.indexOf(' const isBlankPageTarget'), source.indexOf(' pane.appendChild(pageWrap);', source.indexOf(' const isBlankPageTarget')));
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
for (const touch of [true, false]) {
|
||||
const page = await browser.newPage({ viewport: { width: 600, height: 800 }, hasTouch: touch });
|
||||
await page.setContent('<div id="page" style="position:relative;width:500px;height:700px;background:white"></div>');
|
||||
await page.evaluate(({ build, state, placement }) => {
|
||||
new Function(`
|
||||
const activeDocId = 'test', docId = activeDocId;
|
||||
const _pdfPaneAnnotationsByDoc = new Map([['test', []]]);
|
||||
const annotationRefs = _pdfPaneAnnotationsByDoc.get('test');
|
||||
const _pdfLastLineHeight = new Map(), _pdfLastFontSize = new Map();
|
||||
let _pdfDropMode = 'check', serial = 0;
|
||||
const _newAnnotationId = () => 'new-' + (++serial);
|
||||
const _pushPdfUndoSnapshot = () => {};
|
||||
const _schedulePdfPaneSave = () => {};
|
||||
const pageWrap = document.getElementById('page');
|
||||
const page = {page: 1};
|
||||
${state}
|
||||
${build}
|
||||
${placement}
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const {ref} = _buildAnnotation(pageWrap, {id:'a'+i, page:1, x:20+i*35, y:30, w:5, h:5, kind:'check'});
|
||||
annotationRefs.push(ref);
|
||||
ref._setHandlesVisible(true);
|
||||
}
|
||||
window.refs = annotationRefs;
|
||||
`)();
|
||||
}, { build, state, placement });
|
||||
const grip = page.locator('[data-ann-id="a0"] [title="Drag to move"]');
|
||||
const box = await grip.boundingBox();
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(300, 220);
|
||||
assert(await page.evaluate(() => refs.every(ref => [...ref.wrap.querySelectorAll('[title]')].every(el => el.style.display === 'none'))));
|
||||
// Proximity updates must not bring controls back while dragging.
|
||||
await page.evaluate(() => refs.forEach(ref => ref._setHandlesVisible(true)));
|
||||
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isHidden());
|
||||
await page.mouse.up();
|
||||
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
|
||||
assert(await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').isVisible());
|
||||
await page.locator('[data-ann-id="a1"] [title="Delete annotation"]').click();
|
||||
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 1);
|
||||
await page.locator('#page').click({ position: { x: 100, y: 450 } });
|
||||
assert.equal(await page.locator('.pdf-annotation-wrap').count(), 2);
|
||||
console.log(`PASS ${touch ? 'touch' : 'mouse'}: drag controls hidden, delete only deletes, blank page still places`);
|
||||
await page.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
@@ -14,23 +14,57 @@ const { extractThemeBootstrap } = require('./helpers/theme_bootstrap.cjs');
|
||||
page.on('request', req => { if (/\.(webm|mp4)(?:$|\?)/.test(req.url())) mediaRequests.push(req.url()); });
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
await page.goto(origin + '/website/index.html');
|
||||
assert.equal(await page.locator('video, .preview-panel, .sec-bg-tint').count(), 0);
|
||||
assert.equal(await page.locator('.feature-description').count(), 8);
|
||||
for (const description of await page.locator('.feature-description .desc').allTextContents()) assert(description.trim());
|
||||
// Landing page contains expected preview panels, preview videos, and background video
|
||||
assert.equal(await page.locator('.preview-panel').count(), 8);
|
||||
assert.equal(await page.locator('section#previews video').count(), 7);
|
||||
assert.equal(await page.locator('section#how video.sec-bg').count(), 1);
|
||||
assert.equal(await page.locator('section#how .sec-bg-tint').count(), 1);
|
||||
|
||||
// Expected video source paths exist and return 200
|
||||
const expectedVideos = [
|
||||
'chat.webm',
|
||||
'research.webm',
|
||||
'compare.webm',
|
||||
'document.webm',
|
||||
'notes.webm',
|
||||
'gallery.webm',
|
||||
'theme.webm',
|
||||
'bg.webm',
|
||||
];
|
||||
const videoSources = await page.locator('video source').evaluateAll(els => els.map(s => s.getAttribute('src')));
|
||||
assert.deepEqual(videoSources.sort(), expectedVideos.slice().sort());
|
||||
for (const src of expectedVideos) {
|
||||
const videoRes = await page.request.get(origin + '/website/' + src);
|
||||
assert.equal(videoRes.status(), 200, `Video source ${src} should return 200`);
|
||||
}
|
||||
|
||||
// Feature and video layout does not overflow at desktop or mobile widths
|
||||
for (const width of [1280, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
assert(await page.locator('.feature-description').first().isVisible());
|
||||
assert(await page.locator('.preview-panel').first().isVisible());
|
||||
assert(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
||||
}
|
||||
assert.deepEqual(mediaRequests, []);
|
||||
assert.deepEqual(errors, []);
|
||||
|
||||
// Manifest has valid icon entries that return successfully
|
||||
const manifest = await (await page.request.get(origin + '/static/manifest.json')).json();
|
||||
assert.equal(manifest.display, 'standalone');
|
||||
assert(!manifest.icons);
|
||||
assert(Array.isArray(manifest.icons) && manifest.icons.length === 3);
|
||||
for (const icon of manifest.icons) {
|
||||
assert(icon.src, 'Manifest icon entry must have src');
|
||||
const iconUrl = new URL(icon.src, origin + '/static/manifest.json').href;
|
||||
const iconRes = await page.request.get(iconUrl);
|
||||
assert.equal(iconRes.status(), 200, `Manifest icon ${icon.src} should return 200`);
|
||||
}
|
||||
|
||||
// Apple touch icon references in app HTML point to real files
|
||||
for (const filename of ['index.html', 'login.html']) {
|
||||
const html = await (await page.request.get(origin + '/static/' + filename)).text();
|
||||
assert(!/<link[^>]+rel=["']apple-touch-icon/.test(html));
|
||||
const match = html.match(/<link[^>]+rel=["']apple-touch-icon["'][^>]+href=["']([^"']+)["']/);
|
||||
assert(match, `missing apple-touch-icon in ${filename}`);
|
||||
const iconUrl = new URL(match[1], origin + '/').href;
|
||||
const res = await page.request.get(iconUrl);
|
||||
assert.equal(res.status(), 200, `Apple touch icon in ${filename} should return 200`);
|
||||
}
|
||||
|
||||
// A small host DOM runs the actual export function and markdown renderer.
|
||||
|
||||
@@ -89,7 +89,7 @@ a status code. `scripts/odysseus-smoke --areas` prints the current list.
|
||||
| Email | the fixture inbox lists, opens with its body, and the unread count drops on mark-read |
|
||||
| Memory | a fact is listed, found by search, and gone after delete |
|
||||
| Uploads | an attachment reads back byte for byte |
|
||||
| Cookbook | hardware is detected; a fresh install's recommendation request returns the explicit empty-catalog Rescan guidance; state persists |
|
||||
| Cookbook | hardware is detected and recommendations come back sized against it; state persists |
|
||||
| Settings | a preference written on one session is still there after a new login |
|
||||
|
||||
## What is not covered, and why
|
||||
|
||||
@@ -20,3 +20,4 @@ def test_terse_timed_calendar_create_promotes_to_calendar():
|
||||
|
||||
assert intent.needs_tools
|
||||
assert intent.category == "calendar"
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from src.agent_evidence import (
|
||||
CompletionRequirements,
|
||||
CompletionStatus,
|
||||
@@ -934,3 +936,66 @@ def test_exhaustion_and_awaiting_user_are_non_completion_states():
|
||||
ledger = EvidenceLedger()
|
||||
assert ledger.evaluate(exhausted=True).status == CompletionStatus.EXHAUSTED
|
||||
assert ledger.evaluate(awaiting_user=True).status == CompletionStatus.AWAITING_USER
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tool", ["create_document", "update_document", "edit_document"])
|
||||
def test_authoritative_document_write_records_document_mutation_evidence(tool):
|
||||
ledger = EvidenceLedger.from_tool_events([
|
||||
{"tool": tool, "command": "fix grammar", "exit_code": 0},
|
||||
])
|
||||
|
||||
mutations = [event for event in ledger.events
|
||||
if event.kind == EvidenceKind.DOCUMENT_MUTATION]
|
||||
assert len(mutations) == 1
|
||||
assert mutations[0].authoritative and mutations[0].success
|
||||
assert ledger._supports_document_mutation() is True
|
||||
|
||||
|
||||
def test_non_authoritative_document_write_is_not_mutation_evidence():
|
||||
ledger = EvidenceLedger.from_tool_events([
|
||||
{"tool": "update_document", "command": "fix grammar"},
|
||||
])
|
||||
|
||||
assert ledger._supports_document_mutation() is False
|
||||
|
||||
|
||||
def test_successful_private_write_supports_mutation_claim_without_artifact():
|
||||
# manage_calendar:create_event has no filesystem artifact, so the completion
|
||||
# verifier previously erased the model's "I created the meeting" claim. A
|
||||
# successful authoritative owned-state write must now support it.
|
||||
from src.agent_runtime.completion import completion_answer
|
||||
|
||||
ledger = EvidenceLedger.from_tool_events([
|
||||
{
|
||||
"round": 1,
|
||||
"tool": "manage_calendar",
|
||||
"command": json.dumps({
|
||||
"action": "create_event",
|
||||
"title": "Meeting with Sam",
|
||||
"start": "2026-10-16T15:00:00",
|
||||
}),
|
||||
"output": "Created event [Meeting with Sam]",
|
||||
"exit_code": 0,
|
||||
"execution_attempted": True,
|
||||
},
|
||||
])
|
||||
assert ledger._private_action_mutations == ["manage_calendar:create_event"]
|
||||
|
||||
answer, _ = completion_answer(
|
||||
"I created the meeting with Sam.", ledger, ledger.evaluate())
|
||||
assert "created the meeting with Sam" in answer
|
||||
assert not answer.startswith("Unsupported execution claims")
|
||||
|
||||
|
||||
def test_failed_private_write_does_not_support_mutation_claim():
|
||||
ledger = EvidenceLedger.from_tool_events([
|
||||
{
|
||||
"round": 1,
|
||||
"tool": "manage_calendar",
|
||||
"command": json.dumps({"action": "create_event", "title": "x"}),
|
||||
"output": "denied",
|
||||
"exit_code": 1,
|
||||
"execution_attempted": True,
|
||||
},
|
||||
])
|
||||
assert ledger._private_action_mutations == []
|
||||
|
||||
@@ -1873,8 +1873,7 @@ def test_tui_local_skill_request_reconciles_normal_tool_policy(monkeypatch):
|
||||
},
|
||||
)))
|
||||
|
||||
# Caller hard denials are absolute and cannot be re-enabled by later TUI turn contracts.
|
||||
assert calls == []
|
||||
assert calls == ["manage_skills"]
|
||||
assert not any(e.get("type") == "loop_breaker_triggered" for e in events)
|
||||
|
||||
|
||||
|
||||
@@ -176,6 +176,41 @@ def test_native_file_tools_hide_control_plane_hardlink_alias(tmp_path, monkeypat
|
||||
assert ":1:LIVE_ADMIN_SESSION" not in grep_result["output"]
|
||||
|
||||
|
||||
def test_native_file_tools_take_one_control_plane_snapshot_per_scan(tmp_path, monkeypatch):
|
||||
"""The deny check must not rebuild the control-plane snapshot per entry.
|
||||
|
||||
Each rebuild stats every protected state file, so a per-entry rebuild made
|
||||
a 5,000-file grep about seven times slower and timed out near 20k files.
|
||||
"""
|
||||
resources = importlib.import_module("src.agent_runtime.resources")
|
||||
data_dir = tmp_path / "data"
|
||||
data_dir.mkdir()
|
||||
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
|
||||
for index in range(3):
|
||||
directory = workspace / f"d{index}"
|
||||
directory.mkdir(parents=True)
|
||||
for item in range(20):
|
||||
(directory / f"f{item}.txt").write_text("NEEDLE\n" if item == 0 else "x\n")
|
||||
snapshots = []
|
||||
original = resources._control_plane_snapshot
|
||||
|
||||
def counting_snapshot():
|
||||
snapshots.append(1)
|
||||
return original()
|
||||
|
||||
monkeypatch.setattr(resources, "_control_plane_snapshot", counting_snapshot)
|
||||
for tool, args, expected in (
|
||||
(LsTool(), {"path": str(workspace / "d0")}, "f19.txt"),
|
||||
(GlobTool(), {"pattern": "**/*.txt", "path": str(workspace)}, "f19.txt"),
|
||||
(GrepTool(), {"pattern": "NEEDLE", "path": str(workspace)}, "f0.txt:1:NEEDLE"),
|
||||
):
|
||||
snapshots.clear()
|
||||
result = asyncio.run(tool.execute(json.dumps(args), {}))
|
||||
assert expected in result.get("output", ""), result
|
||||
# One fresh check resolves the search root; the walk shares one more.
|
||||
assert len(snapshots) <= 2, (type(tool).__name__, len(snapshots))
|
||||
|
||||
|
||||
def test_blocks_app_state_on_a_case_insensitive_filesystem():
|
||||
"""On default macOS a case-variant path opens the same file, and realpath
|
||||
does not canonicalise case there the way it does on Windows.
|
||||
@@ -331,7 +366,9 @@ def test_allows_files_in_the_agent_workspace():
|
||||
(PERSONAL_UPLOADS_DIR,
|
||||
"indexed into personal docs by routes/personal_routes.py, and listed as "
|
||||
"an absolute path by manage_rag"),
|
||||
])
|
||||
# The directories live under a per-worker data dir, so they cannot be test ids:
|
||||
# xdist requires every worker to collect the same names.
|
||||
], ids=["uploads", "mail_attachments", "personal", "personal_uploads"])
|
||||
def test_allows_user_content_the_app_hands_to_the_model(directory, why):
|
||||
"""Carving these out is not convenience. The app gives the model these
|
||||
paths and tells it to read them, so denying them breaks the feature."""
|
||||
|
||||
@@ -87,6 +87,91 @@ def test_ask_user_is_emitted_last_and_persisted(monkeypatch):
|
||||
assert metrics["tool_events"][0]["ask_user"] == payload
|
||||
|
||||
|
||||
def test_clean_preview_ask_user_ends_turn_without_second_round(monkeypatch):
|
||||
"""The compact ``stream_preview`` runtime must treat ask_user as a turn boundary.
|
||||
|
||||
Regression for chat d8dca715: the preview fed the ask_user tool result
|
||||
back to the model, so it burned a second provider round and persisted the
|
||||
model's follow-up prose instead of the question.
|
||||
"""
|
||||
import src.clean_agent_preview as module
|
||||
from src.tool_schemas import FUNCTION_TOOL_SCHEMAS
|
||||
from src.tool_policy import ToolPolicy
|
||||
from src.turn_contract import resolve_full_inventory_contract
|
||||
|
||||
payload = {
|
||||
"question": "What would be most useful to start with?",
|
||||
"options": [
|
||||
{"label": "A 30-day meditation plan"},
|
||||
{"label": "How to measure brain activity"},
|
||||
],
|
||||
"multi": False,
|
||||
}
|
||||
requests = []
|
||||
responses = iter([
|
||||
{'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': 'ask-1',
|
||||
'function': {'name': 'ask_user',
|
||||
'arguments': json.dumps(payload, ensure_ascii=False)}}]}}]},
|
||||
{'choices': [{'delta': {'content': 'SHOULD NOT BE REQUESTED'}}]},
|
||||
])
|
||||
|
||||
class Response:
|
||||
def __init__(self, body): self.body = body
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *args): pass
|
||||
def raise_for_status(self): pass
|
||||
async def aiter_lines(self):
|
||||
yield 'data: ' + json.dumps(self.body)
|
||||
yield 'data: [DONE]'
|
||||
|
||||
class Client:
|
||||
def __init__(self, **kwargs): pass
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *args): pass
|
||||
def stream(self, *args, **kwargs):
|
||||
requests.append(kwargs['json'])
|
||||
return Response(next(responses))
|
||||
|
||||
async def execute(block, **kwargs):
|
||||
parsed = json.loads(block.content)
|
||||
return 'ask_user', {
|
||||
'ask_user': parsed,
|
||||
'output': 'Awaiting their selection.',
|
||||
'exit_code': 0,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
|
||||
monkeypatch.setattr(module, 'execute_tool_block', execute)
|
||||
|
||||
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'ask_user')
|
||||
contract = resolve_full_inventory_contract(schemas=[schema], policy=ToolPolicy())
|
||||
raw = _collect(module.stream_preview(
|
||||
endpoint_url='http://test', model='test', headers={},
|
||||
messages=[{'role': 'user', 'content': 'Help me pick a project.'}],
|
||||
turn_contract=contract, session_id='test', owner='test',
|
||||
disabled_tools=set(), tool_policy=ToolPolicy(),
|
||||
))
|
||||
events = _events(raw)
|
||||
|
||||
# Ending the turn means no second provider round is requested.
|
||||
assert len(requests) == 1
|
||||
ask_user_events = [event for event in events if event.get('type') == 'ask_user']
|
||||
assert len(ask_user_events) == 1
|
||||
assert ask_user_events[0]['data'] == payload
|
||||
# The saved assistant text is the question, not the model's follow-up prose.
|
||||
final = next(event for event in events if event.get('type') == 'final_response')
|
||||
assert final['content'] == payload['question']
|
||||
types = [event.get('type') for event in events]
|
||||
assert types.index('ask_user') < types.index('metrics')
|
||||
# Reconstructable from persisted metadata, and no phantom extra rounds.
|
||||
metrics = next(event['data'] for event in events if event.get('type') == 'metrics')
|
||||
assert metrics['tool_events'][0]['ask_user'] == payload
|
||||
assert metrics['agent_rounds'] == 1
|
||||
assert metrics['clean_v3_turn'][-1] == {
|
||||
'role': 'assistant', 'content': payload['question'],
|
||||
}
|
||||
|
||||
|
||||
def test_frontend_uses_one_renderer_for_live_and_restored_cards():
|
||||
chat = (ROOT / "static" / "js" / "chat.js").read_text(encoding="utf-8")
|
||||
renderer = (ROOT / "static" / "js" / "chatRenderer.js").read_text(encoding="utf-8")
|
||||
|
||||
@@ -60,6 +60,7 @@ def test_login_page_static_assets_resolve_under_mount_path():
|
||||
mounted_login_url = "https://example.test/odysseus/login"
|
||||
for relative_asset, mounted_path in (
|
||||
("static/manifest.json", "/odysseus/static/manifest.json"),
|
||||
("static/icons/icon-192.png", "/odysseus/static/icons/icon-192.png"),
|
||||
(
|
||||
"static/fonts/FiraCode-Regular.woff2",
|
||||
"/odysseus/static/fonts/FiraCode-Regular.woff2",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""A valid login session cookie must survive a deploy restart of the server.
|
||||
|
||||
AuthManager keeps tokens in memory but persists them to sessions.json on create
|
||||
and reloads them at startup, so a restart must NOT log the user out (the 302 the
|
||||
bench saw was the restart gap / a client-side artifact, not session loss).
|
||||
"""
|
||||
from core import auth
|
||||
|
||||
|
||||
def test_session_token_survives_a_restart(tmp_path):
|
||||
auth_path = str(tmp_path / "auth.json")
|
||||
first = auth.AuthManager(auth_path)
|
||||
assert first.create_user("pewds", "hunter2", is_admin=True)
|
||||
token = first.create_session_trusted("pewds")
|
||||
assert token and first.validate_token(token)
|
||||
|
||||
# Simulate a process restart: a brand-new AuthManager on the same data dir.
|
||||
second = auth.AuthManager(auth_path)
|
||||
assert second.validate_token(token) is True
|
||||
assert second.get_username_for_token(token) == "pewds"
|
||||
|
||||
|
||||
def test_revoked_token_does_not_survive_restart(tmp_path):
|
||||
auth_path = str(tmp_path / "auth.json")
|
||||
first = auth.AuthManager(auth_path)
|
||||
first.create_user("pewds", "hunter2", is_admin=True)
|
||||
token = first.create_session_trusted("pewds")
|
||||
first.revoke_token(token)
|
||||
|
||||
second = auth.AuthManager(auth_path)
|
||||
assert second.validate_token(token) is False
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Spec-surface admission: the strict router picks the tools after authority is
|
||||
built, so spec turns admit the router's families; compact/full stay unchanged."""
|
||||
import json
|
||||
|
||||
from src.agent_runtime.authority import create_request_authority, ExactOperation
|
||||
|
||||
|
||||
def _permits(auth, tool, args=None):
|
||||
return auth.permits(ExactOperation.normalize(tool, json.dumps(args or {})))
|
||||
|
||||
|
||||
def _auth(text, **kw):
|
||||
return create_request_authority(text, owner="pewds", session_id="s1", workspace="", **kw)
|
||||
|
||||
|
||||
def test_spec_surface_admits_a_router_chosen_family_tool():
|
||||
auth = _auth("Search for the appointment email", spec_surface=True)
|
||||
assert _permits(auth, "mcp__email__search_emails", {"action": "list", "query": "appointment"})
|
||||
|
||||
|
||||
def test_non_spec_turn_does_not_admit_the_same_call():
|
||||
auth = _auth("Search for the appointment email")
|
||||
assert not _permits(auth, "mcp__email__search_emails", {"action": "list", "query": "appointment"})
|
||||
|
||||
|
||||
def test_spec_surface_admits_browser_interaction_and_batch():
|
||||
auth = _auth("do the thing", spec_surface=True)
|
||||
assert _permits(auth, "private_browser", {"action": "batch"})
|
||||
assert _permits(auth, "private_browser", {"action": "click", "selector": "#go"})
|
||||
assert _permits(auth, "private_browser", {"action": "fill", "selector": "#q", "text": "x"})
|
||||
|
||||
|
||||
def test_batch_is_read_but_click_stays_verb_gated_off_spec():
|
||||
auth = _auth("search the web for the appointment")
|
||||
assert _permits(auth, "private_browser", {"action": "batch"})
|
||||
assert not _permits(auth, "private_browser", {"action": "click", "selector": "#go"})
|
||||
|
||||
|
||||
def test_spec_surface_keeps_the_exact_read_input_seal():
|
||||
auth = _auth("Read note id abc123", spec_surface=True)
|
||||
assert _permits(auth, "manage_notes", {"action": "view", "id": "abc123"})
|
||||
assert not _permits(auth, "manage_notes", {"action": "view", "id": "zzz999"})
|
||||
@@ -49,9 +49,8 @@ def test_scheduler_fallbacks_and_research_headers_are_owner_scoped():
|
||||
assert "owner=task.owner or None" in src
|
||||
assert "headers_from_resolver = False" in src
|
||||
assert "headers_from_resolver = True" in src
|
||||
assert "resolve_owner_registered_endpoint(db2, endpoint_url, task.owner or None)" in src
|
||||
assert "resolve_owner_registered_endpoint(db, endpoint_url, task.owner or None)" in src
|
||||
assert src.count("resolve_endpoint_runtime(ep, owner=task.owner or None)") == 2
|
||||
assert "from src.auth_helpers import owner_filter" in src
|
||||
assert "owner_filter(ep_q, ModelEndpoint, task.owner or None)" in src
|
||||
|
||||
|
||||
def test_research_routes_fallbacks_are_owner_scoped():
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
import json
|
||||
import pytest
|
||||
|
||||
from src.clean_agent_preview import preview_tool_result_text
|
||||
|
||||
|
||||
@pytest.mark.parametrize('snapshot', [None, '', ' '])
|
||||
def test_text_and_title_survive_without_snapshot(snapshot):
|
||||
result = {'url': 'https://example.org', 'title': 'Access denied',
|
||||
'text': 'Unusual traffic. Complete the CAPTCHA.', 'snapshot': snapshot}
|
||||
output = preview_tool_result_text({'output': json.dumps(result)}, 'private_browser', {})
|
||||
assert 'Access denied' in output
|
||||
assert 'Unusual traffic. Complete the CAPTCHA.' in output
|
||||
from src.clean_agent_preview import browser_observation_access_blocked
|
||||
assert browser_observation_access_blocked(output)
|
||||
|
||||
|
||||
def test_snapshot_takes_precedence_over_duplicate_page_text():
|
||||
output = preview_tool_result_text({'output': json.dumps({
|
||||
'snapshot': '- link "Report" [ref=e1]', 'text': 'Duplicated page body',
|
||||
})}, 'private_browser', {})
|
||||
assert '[ref=e1]' in output
|
||||
assert 'Duplicated page body' not in output
|
||||
|
||||
|
||||
def test_legacy_page_text_retains_access_block_evidence():
|
||||
result = {'url': 'https://example.org', 'title': 'Security verification',
|
||||
'text': 'Unusual traffic. Complete the CAPTCHA.'}
|
||||
|
||||
@@ -49,6 +49,9 @@ async def _create_with_reminder(reminder, owner):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reminder,expected", [
|
||||
(0, 0), # At-event reminder must not compare equal to False.
|
||||
(0.0, 0),
|
||||
("0", 0),
|
||||
("5 mins", 5),
|
||||
("10 mins", 10),
|
||||
("2 hrs", 120),
|
||||
@@ -91,6 +94,15 @@ async def test_no_reminder_when_offset_absent():
|
||||
assert "reminder set" not in res.get("response", ""), res
|
||||
|
||||
|
||||
@pytest.mark.parametrize("reminder", [False, "off", "false", None])
|
||||
async def test_disabled_reminder_is_distinct_from_zero(reminder):
|
||||
owner = "tester-" + uuid.uuid4().hex[:6]
|
||||
res = await _create_with_reminder(reminder, owner)
|
||||
assert res.get("exit_code") == 0, res
|
||||
assert not res.get("has_reminder"), res
|
||||
assert res.get("reminder_note_id") is None, res
|
||||
|
||||
|
||||
async def test_update_event_can_add_reminder_after_creation():
|
||||
owner = "tester-" + uuid.uuid4().hex[:6]
|
||||
from src.tool_implementations import do_manage_calendar
|
||||
@@ -123,37 +135,3 @@ async def test_update_event_can_add_reminder_after_creation():
|
||||
assert note is not None, "update_event should create the reminder note"
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
async def test_reminder_and_duration_parsers_stay_linear_on_digit_and_space_floods():
|
||||
"""CodeQL py/polynomial-redos: `(\\d+)\\s*unit` rescanned a digit run from
|
||||
every offset and `alarm\\s*:?\\s*\\d+` split one whitespace run two ways.
|
||||
Tool arguments come from model output, so keep them O(n)."""
|
||||
import time
|
||||
from src.tool_implementations import do_manage_calendar
|
||||
|
||||
owner = "tester-" + uuid.uuid4().hex[:6]
|
||||
started = time.perf_counter()
|
||||
res = await do_manage_calendar(json.dumps({
|
||||
"action": "create_event",
|
||||
"summary": "Flood",
|
||||
"dtstart": "2030-04-01T10:00:00",
|
||||
"reminder_minutes": "0" * 40_000 + "x",
|
||||
"duration": "0" * 40_000 + "x",
|
||||
}), owner=owner)
|
||||
assert time.perf_counter() - started < 2.0
|
||||
assert res.get("exit_code") == 0, res
|
||||
assert "reminder set" not in res.get("response", ""), res
|
||||
|
||||
# A reminder is set, so the "is the description only a reminder?" check runs.
|
||||
started = time.perf_counter()
|
||||
res = await do_manage_calendar(json.dumps({
|
||||
"action": "create_event",
|
||||
"summary": "Flood 2",
|
||||
"dtstart": "2030-04-02T10:00:00",
|
||||
"description": "alarm" + "\t" * 40_000,
|
||||
"reminder_minutes": 5,
|
||||
}), owner=owner)
|
||||
assert time.perf_counter() - started < 2.0
|
||||
assert res.get("exit_code") == 0, res
|
||||
assert "reminder 5 min before" in res.get("response", ""), res
|
||||
|
||||
@@ -231,6 +231,25 @@ def test_disabled_tools_respects_missing_vs_explicit_toggles():
|
||||
)
|
||||
|
||||
|
||||
def test_request_authority_grants_web_when_turn_offers_web_tools():
|
||||
"""Web tools offered for a turn must carry request authority too.
|
||||
|
||||
Regression: the authority capabilities gate only recognized explicit
|
||||
browser wording (``_explicit_browser_intent``/``_external_discovery_intent``),
|
||||
so an implicit research question with the Web toggle on offered
|
||||
``web_search`` in the model's schema but rejected every call with
|
||||
``request_authority_denied``. The gate must also grant ``search_browser``
|
||||
whenever web tools are not disabled for the turn.
|
||||
"""
|
||||
source = _CHAT_ROUTES.read_text(encoding="utf-8")
|
||||
assert "({'search_browser'} if (" in source, (
|
||||
"request authority must be built with a search_browser capabilities gate"
|
||||
)
|
||||
assert "or \"web_search\" not in disabled_tools" in source, (
|
||||
"authority must grant search_browser whenever web_search is offered"
|
||||
)
|
||||
|
||||
|
||||
def test_explicit_private_browser_workflow_survives_web_search_disabled():
|
||||
source = _CHAT_ROUTES.read_text(encoding="utf-8")
|
||||
assert "_explicit_private_browser_intent" in source
|
||||
@@ -748,3 +767,21 @@ def test_frontend_sends_explicit_allow_web_search_false_in_agent_mode():
|
||||
assert "fd.append('allow_web_search', el('web-toggle').checked ? 'true' : 'false')" in source, (
|
||||
"Frontend must send explicit allow_web_search=false in agent mode when toggle is off"
|
||||
)
|
||||
|
||||
|
||||
def test_spec_surface_skips_the_contract_scope_tool_denial():
|
||||
"""On the strict-router (spec) surface the regex turn contract must NOT
|
||||
scope disabled_tools, or the spec router menu (email/notes/...) is emptied
|
||||
before src/spec_router.py runs. Compact/full/none keep the denial."""
|
||||
source = _CHAT_ROUTES.read_text(encoding="utf-8")
|
||||
assert "if not _spec_surface:" in source, (
|
||||
"the contract-scope disabled_tools.update must be guarded for spec turns"
|
||||
)
|
||||
idx = source.index("if not _spec_surface:")
|
||||
segment = source[idx:idx + 500]
|
||||
assert "disabled_tools.update(" in segment, (
|
||||
"the contract-scope denial must live inside the spec guard"
|
||||
)
|
||||
assert "if not _turn_contract.permits(" in segment, (
|
||||
"the guarded block must be the contract-scope denial"
|
||||
)
|
||||
|
||||
@@ -109,12 +109,37 @@ def test_deepseek_flash_keeps_tools_but_drops_unsupported_forced_choice():
|
||||
}
|
||||
|
||||
compatible = provider_compatible_tool_choice_request(request, 'deepseek-flash')
|
||||
assert [tool['function']['name'] for tool in compatible['tools']] == ['inspect_media']
|
||||
# A forced named choice must not hide the other offered tools.
|
||||
assert [tool['function']['name'] for tool in compatible['tools']] == [
|
||||
'inspect_media', 'python'
|
||||
]
|
||||
assert 'tool_choice' not in compatible
|
||||
assert request['tool_choice']['function']['name'] == 'inspect_media'
|
||||
assert provider_compatible_tool_choice_request(request, 'qwen3.5-9b') is request
|
||||
|
||||
|
||||
def test_deepseek_forced_read_does_not_hide_other_offered_tools():
|
||||
# Regression: a required calendar read forced tool_choice=manage_calendar on
|
||||
# a turn that also offered the email tools; the DeepSeek compatibility path
|
||||
# used to shrink `tools` to manage_calendar only, so the model reported the
|
||||
# email tools the user asked for were unavailable.
|
||||
request = {
|
||||
'model': 'deepseek-v4-flash',
|
||||
'tools': [
|
||||
{'type': 'function', 'function': {'name': 'manage_calendar'}},
|
||||
{'type': 'function', 'function': {'name': 'mcp__email__search_emails'}},
|
||||
{'type': 'function', 'function': {'name': 'mcp__email__read_email'}},
|
||||
],
|
||||
'tool_choice': {'type': 'function', 'function': {'name': 'manage_calendar'}},
|
||||
}
|
||||
compatible = provider_compatible_tool_choice_request(request, 'deepseek-v4-flash')
|
||||
names = [tool['function']['name'] for tool in compatible['tools']]
|
||||
assert names == [
|
||||
'manage_calendar', 'mcp__email__search_emails', 'mcp__email__read_email'
|
||||
]
|
||||
assert 'tool_choice' not in compatible
|
||||
|
||||
|
||||
def test_deepseek_v4_flash_drops_named_choice_for_thinking_mode():
|
||||
request = {
|
||||
'tools': [{'type': 'function', 'function': {'name': 'write_file'}}],
|
||||
@@ -136,8 +161,10 @@ def test_kimi_k3_drops_named_choice_incompatible_with_thinking_mode():
|
||||
|
||||
compatible = provider_compatible_tool_choice_request(request, 'kimi-k3')
|
||||
|
||||
# The full tool surface is preserved; only the unsupported forced choice
|
||||
# is dropped.
|
||||
assert [tool['function']['name'] for tool in compatible['tools']] == [
|
||||
'inspect_media'
|
||||
'inspect_media', 'write_file'
|
||||
]
|
||||
assert 'tool_choice' not in compatible
|
||||
|
||||
@@ -210,8 +237,8 @@ def test_private_browser_covered_click_does_not_advance_dom_revision():
|
||||
def test_compact_notes_preserves_create_vs_edit_and_replacement_semantics():
|
||||
schema = next(s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'manage_notes')
|
||||
compact = compact_schemas([schema])[0]['function']
|
||||
assert 'add creates a new note' in compact['description']
|
||||
assert 'update with id' in compact['description']
|
||||
assert 'ONE add call' in compact['description']
|
||||
assert 'update with id edits an existing note, never add' in compact['description']
|
||||
assert 'replaces the whole checklist' in compact['parameters']['properties']['checklist_items']['description']
|
||||
|
||||
|
||||
@@ -3511,6 +3538,10 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
|
||||
'web_fetch': ({'url': 'https://example.com'}, 'read this page'),
|
||||
'web_search': ({'query': 'current AI news'}, 'search the web'),
|
||||
'youtube_tool': ({'action': 'metadata', 'video_url': 'https://youtube.com/watch?v=x'}, 'read YouTube metadata'),
|
||||
'plan_edit': ({'prompt': 'make it black and white'}, 'make it black and white'),
|
||||
'run_editor_command': ({'op': 'document.info'}, 'inspect the document'),
|
||||
'get_editor_screenshot': ({}, 'take a screenshot of the editor'),
|
||||
'ai_edit_image': ({'op': 'remove-bg'}, 'remove the background'),
|
||||
}
|
||||
schemas = {
|
||||
schema['function']['name']: schema
|
||||
@@ -3537,6 +3568,9 @@ def test_every_compactly_offered_preview_tool_has_valid_policy_permitted_call():
|
||||
}
|
||||
else {'image_editing'} if name == 'edit_image'
|
||||
else {'image_generation'} if name == 'generate_image'
|
||||
else {'photo_editor'} if name in {
|
||||
'plan_edit', 'run_editor_command', 'get_editor_screenshot', 'ai_edit_image',
|
||||
}
|
||||
else frozenset()
|
||||
),
|
||||
contract_required_tools={name},
|
||||
@@ -4366,21 +4400,20 @@ def test_v3_schema_uses_configured_versioned_contract_root(tmp_path, monkeypatch
|
||||
(contract_root / 'eval_alltools_unseen_compare.py').write_text(
|
||||
"def tools_for_mode(tools, mode):\n"
|
||||
" assert mode == 'compact_contract_v5'\n"
|
||||
" tools[0]['function']['description'] = 'versioned-contract-loaded'\n"
|
||||
" tools[0]['function']['parameters']['properties']['action']['description'] = 'versioned-contract-loaded'\n"
|
||||
" return tools\n",
|
||||
encoding='utf-8',
|
||||
)
|
||||
monkeypatch.setenv('ODYSSEUS_TOOL_CONTRACT_ROOT', str(contract_root))
|
||||
module.contract_builder.cache_clear()
|
||||
try:
|
||||
# Probe a tool whose compact description the harness does not
|
||||
# replace; manage_notes now carries a full harness-owned override.
|
||||
search = next(
|
||||
notes = next(
|
||||
s for s in FUNCTION_TOOL_SCHEMAS
|
||||
if s['function']['name'] == 'web_search'
|
||||
if s['function']['name'] == 'manage_notes'
|
||||
)
|
||||
compact = module.compact_schemas([search])[0]
|
||||
assert compact['function']['description'].startswith('versioned-contract-loaded')
|
||||
compact = module.compact_schemas([notes])[0]
|
||||
assert compact['function']['parameters']['properties']['action']['description'] == 'versioned-contract-loaded'
|
||||
assert 'update with id edits an existing note, never add' in compact['function']['description']
|
||||
finally:
|
||||
module.contract_builder.cache_clear()
|
||||
|
||||
@@ -4392,6 +4425,7 @@ def test_v3_document_edit_schema_has_one_unambiguous_structured_form():
|
||||
assert parameters['required'] == ['edits']
|
||||
assert set(parameters['properties']) == {'edits', 'more'}
|
||||
assert parameters['properties']['more']['type'] == 'boolean'
|
||||
assert 'more' not in parameters['required']
|
||||
|
||||
|
||||
def test_v3_ui_schema_advertises_only_policy_executable_client_local_actions():
|
||||
@@ -4526,7 +4560,7 @@ async def test_stream_emits_incremental_text_and_persistable_history(monkeypatch
|
||||
raw = [chunk async for chunk in stream_preview(endpoint_url='http://test', model='test', messages=[{'role': 'user', 'content': 'hi'}], headers={}, turn_contract=contract, session_id='test', owner='test', disabled_tools=set(), tool_policy=ToolPolicy())]
|
||||
events = [json.loads(s[6:]) for s in raw if '[DONE]' not in s]
|
||||
assert [e['delta'] for e in events if 'delta' in e] == ['Hello', ' there']
|
||||
assert requests[0]['messages'][0]['content'].startswith('You are Odysseus.')
|
||||
assert requests[0]['messages'][0]['content'].startswith('You are Ajax, the assistant in Odysseus.')
|
||||
metrics = next(e['data'] for e in events if e.get('type') == 'metrics')
|
||||
assert metrics['clean_v3_turn'] == [{'role': 'assistant', 'content': 'Hello there'}]
|
||||
assert raw[-1] == 'data: [DONE]\n\n'
|
||||
@@ -4562,9 +4596,7 @@ async def test_preview_provider_stream_error_is_terminal_not_empty_answer(monkey
|
||||
disabled_tools=set(), tool_policy=ToolPolicy(),
|
||||
)]
|
||||
assert raw[-1].startswith('event: error\ndata: ')
|
||||
assert 'selected model provider failed' in raw[-1]
|
||||
assert 'provider_stream_error' in raw[-1]
|
||||
assert 'Qwen3_5MTPDraftModel' not in raw[-1]
|
||||
assert 'Qwen3_5MTPDraftModel' in raw[-1]
|
||||
assert all('returned no answer' not in chunk for chunk in raw)
|
||||
assert all('"type": "metrics"' not in chunk for chunk in raw)
|
||||
|
||||
@@ -4664,6 +4696,74 @@ def test_ajax_c375_tool_surface_disables_progressive_thinking():
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name,args,reason', [
|
||||
('python', {}, 'code'),
|
||||
('apply_patch', {}, 'code'),
|
||||
('edit_document', {}, 'document_edit'),
|
||||
('draft_email_reply', {}, 'email_draft'),
|
||||
('manage_tasks', {'action': 'create'}, 'task_design'),
|
||||
('manage_skills', {'action': 'publish'}, 'skill_evaluation'),
|
||||
('manage_tasks', {'action': 'list'}, ''),
|
||||
('manage_tasks', {'action': 'pause'}, ''),
|
||||
('manage_skills', {'action': 'view'}, ''),
|
||||
('read_file', {}, ''),
|
||||
('web_search', {}, ''),
|
||||
('manage_notes', {'action': 'add'}, ''),
|
||||
])
|
||||
def test_tool_deliberation_is_operation_specific(name, args, reason):
|
||||
from src.clean_agent_preview import tool_deliberation_reason, progressive_thinking_for_turn
|
||||
assert tool_deliberation_reason(name, args) == reason
|
||||
schemas = [{'function': {'name': name}}]
|
||||
assert progressive_thinking_for_turn('ajax_c375', schemas, deliberate=bool(reason)) == bool(reason)
|
||||
assert not progressive_thinking_for_turn('other-model', schemas, deliberate=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_creation_deliberates_before_executing_once(monkeypatch):
|
||||
import src.clean_agent_preview as module
|
||||
requests, executions = [], []
|
||||
arguments = {'action': 'create', 'name': 'Morning news', 'prompt': 'Summarize news', 'schedule': 'daily', 'time': '09:00'}
|
||||
proposal = {'choices': [{'delta': {'tool_calls': [{'index': 0, 'id': 'task-call',
|
||||
'function': {'name': 'manage_tasks', 'arguments': json.dumps(arguments)}}]}}]}
|
||||
responses = iter([proposal, proposal, {'choices': [{'delta': {'content': 'Created the task.'}}]}])
|
||||
|
||||
class Response:
|
||||
def __init__(self, payload): self.payload = payload
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *args): pass
|
||||
def raise_for_status(self): pass
|
||||
async def aiter_lines(self):
|
||||
yield 'data: ' + json.dumps(self.payload)
|
||||
yield 'data: [DONE]'
|
||||
|
||||
class Client:
|
||||
def __init__(self, **kwargs): pass
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *args): pass
|
||||
def stream(self, *args, **kwargs):
|
||||
requests.append(kwargs['json'])
|
||||
return Response(next(responses))
|
||||
|
||||
async def execute(block, **kwargs):
|
||||
executions.append(block)
|
||||
return 'manage_tasks', {'output': 'Created task task-1', 'exit_code': 0}
|
||||
|
||||
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
|
||||
monkeypatch.setattr(module, 'execute_tool_block', execute)
|
||||
schemas = [s for s in FUNCTION_TOOL_SCHEMAS if s['function']['name'] == 'manage_tasks']
|
||||
contract = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy())
|
||||
raw = [chunk async for chunk in stream_preview(
|
||||
endpoint_url='http://test', model='ajax_c375',
|
||||
messages=[{'role': 'user', 'content': 'Create a daily task at 9am to summarize news.'}],
|
||||
headers={}, turn_contract=contract, session_id='test', owner='test',
|
||||
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4,
|
||||
)]
|
||||
assert len(executions) == 1, '\n'.join(raw)
|
||||
assert [r['chat_template_kwargs']['enable_thinking'] for r in requests] == [False, True, False]
|
||||
assert not any(m.get('tool_calls') for m in requests[1]['messages'])
|
||||
assert not any('event: error' in chunk for chunk in raw)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calendar_list_structured_result_owns_linked_terminal_render(monkeypatch):
|
||||
import src.clean_agent_preview as module
|
||||
@@ -5427,7 +5527,9 @@ async def test_model_choice_budget_preserves_evidence_for_final_answer_without_e
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkeypatch):
|
||||
@pytest.mark.parametrize('thinking_mode', ['auto', 'off', 'on'])
|
||||
@pytest.mark.parametrize('token_limit', [None, 1024])
|
||||
async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkeypatch, thinking_mode, token_limit):
|
||||
from dataclasses import replace
|
||||
import src.clean_agent_preview as module
|
||||
|
||||
@@ -5489,13 +5591,17 @@ async def test_failed_static_fetch_recovers_once_through_rendered_browser(monkey
|
||||
routing_experiment='recent_model_choice',
|
||||
)
|
||||
raw = [chunk async for chunk in stream_preview(
|
||||
endpoint_url='http://test', model='test',
|
||||
endpoint_url='http://test', model='ajax_c375', thinking_mode=thinking_mode,
|
||||
messages=[{'role': 'user', 'content': 'Tell me more about that Reuters report.'}],
|
||||
headers={}, turn_contract=contract, session_id='test', owner='test',
|
||||
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4,
|
||||
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4, max_tokens=token_limit,
|
||||
)]
|
||||
|
||||
assert executions == ['web_fetch', 'private_browser', 'web_fetch']
|
||||
thinking = [r['chat_template_kwargs']['enable_thinking'] for r in requests]
|
||||
# Compact owns the protocol, including for old sessions with On/Off saved.
|
||||
assert thinking == [False, True, True, False]
|
||||
assert requests[1]['max_tokens'] == (token_limit or 4096)
|
||||
assert all(
|
||||
schema['function']['name'] != 'web_fetch'
|
||||
for schema in requests[1].get('tools', [])
|
||||
@@ -5572,7 +5678,8 @@ async def test_duplicate_fetch_does_not_hide_distinct_pagination_request(monkeyp
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatch):
|
||||
@pytest.mark.parametrize('empty_challenge', [False, True])
|
||||
async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatch, empty_challenge):
|
||||
import src.clean_agent_preview as module
|
||||
|
||||
responses = iter([
|
||||
@@ -5594,10 +5701,8 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
|
||||
},
|
||||
}]}}]},
|
||||
{'choices': [{'delta': {'content': (
|
||||
'Recent AI developments include new model releases and policy updates. '
|
||||
'The search results identify the relevant primary sources and dates for each item, '
|
||||
'including publication details, concrete findings, and links readers can inspect '
|
||||
'for the full context behind each development.'
|
||||
'I found the [AI update](https://example.com/ai) page. '
|
||||
'The browser search was blocked, so I used native search to locate it.'
|
||||
)}}]},
|
||||
])
|
||||
requests = []
|
||||
@@ -5626,6 +5731,9 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
|
||||
if block.tool_type == 'private_browser':
|
||||
return 'private_browser', {
|
||||
'output': json.dumps({
|
||||
'url': 'https://www.google.com/sorry/index?continue=search',
|
||||
'title': 'Client Challenge', 'snapshot': '(empty page)',
|
||||
} if empty_challenge else {
|
||||
'url': 'https://www.google.com/sorry/index?continue=search',
|
||||
'text': 'Our systems have detected unusual traffic. Complete the reCAPTCHA.',
|
||||
}),
|
||||
@@ -5651,7 +5759,7 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
|
||||
)
|
||||
raw = [chunk async for chunk in stream_preview(
|
||||
endpoint_url='http://test', model='test',
|
||||
messages=[{'role': 'user', 'content': 'Open browser and find an AI model release.'}],
|
||||
messages=[{'role': 'user', 'content': 'Open browser and find the AI update page on example.com. Give me its link.'}],
|
||||
headers={}, turn_contract=contract, session_id='test', owner='test',
|
||||
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=4,
|
||||
)]
|
||||
@@ -5659,6 +5767,7 @@ async def test_blocked_search_engine_browser_forces_native_web_search(monkeypatc
|
||||
assert executions == ['private_browser', 'web_search']
|
||||
assert requests[1]['tool_choice'] == 'required'
|
||||
assert [s['function']['name'] for s in requests[1]['tools']] == ['web_search']
|
||||
assert not any(chunk.startswith('event: error') for chunk in raw), raw
|
||||
events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk]
|
||||
assert any(
|
||||
event.get('type') == 'tool_loop_recovery'
|
||||
@@ -5721,9 +5830,10 @@ async def test_native_stream_reserves_remaining_budget_for_required_artifact(mon
|
||||
|
||||
async def execute(block, **kwargs):
|
||||
executed.append(block.tool_type)
|
||||
return block.tool_type, {"output": "ok", "exit_code": 0,
|
||||
"materialized_artifacts": ["/tmp_workspace/results"]
|
||||
if block.tool_type == "python" and "out.md" in block.content else []}
|
||||
if block.tool_type == 'python' and '/tmp_workspace/results/out.md' in block.content:
|
||||
return block.tool_type, {'output': 'ok', 'exit_code': 0,
|
||||
'materialized_artifacts': ['/tmp_workspace/results']}
|
||||
return block.tool_type, {"output": "ok", "exit_code": 0}
|
||||
|
||||
monkeypatch.setattr(module.httpx, "AsyncClient", Client)
|
||||
monkeypatch.setattr(module, "execute_tool_block", execute)
|
||||
@@ -5835,9 +5945,10 @@ async def test_native_stream_reserves_wall_time_for_required_artifact(monkeypatc
|
||||
executed.append(block.tool_type)
|
||||
if block.tool_type == "web_search":
|
||||
now[0] = 450.0
|
||||
return block.tool_type, {"output": "ok", "exit_code": 0,
|
||||
"materialized_artifacts": ["/tmp_workspace/results"]
|
||||
if block.tool_type == "python" and "out.md" in block.content else []}
|
||||
if block.tool_type == 'python' and '/tmp_workspace/results/out.md' in block.content:
|
||||
return block.tool_type, {'output': 'ok', 'exit_code': 0,
|
||||
'materialized_artifacts': ['/tmp_workspace/results']}
|
||||
return block.tool_type, {"output": "ok", "exit_code": 0}
|
||||
|
||||
monkeypatch.setattr(module.time, "monotonic", lambda: now[0])
|
||||
monkeypatch.setattr(module.httpx, "AsyncClient", Client)
|
||||
@@ -6429,14 +6540,15 @@ async def test_native_stream_permanently_withholds_tool_when_model_ignores_dupli
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_stream_terminates_after_calling_a_permanently_suppressed_tool(monkeypatch):
|
||||
@pytest.mark.parametrize('ignores_final_instruction', [False, True])
|
||||
async def test_native_stream_terminates_after_calling_a_permanently_suppressed_tool(monkeypatch, ignores_final_instruction):
|
||||
import src.clean_agent_preview as module
|
||||
arguments = json.dumps({"path": "/workspace/fixtures/video.mp4"})
|
||||
responses = iter([
|
||||
{"choices": [{"delta": {"tool_calls": [{"index": 0, "id": f"inspect-{index}", "function": {
|
||||
"name": "inspect_media", "arguments": arguments,
|
||||
}}]}}]}
|
||||
for index in range(1, 4)
|
||||
for index in range(1, 5 if ignores_final_instruction else 4)
|
||||
] + [{"choices": [{"delta": {"content": "Final answer from existing evidence."}}]}])
|
||||
|
||||
class Response:
|
||||
@@ -6488,9 +6600,15 @@ async def test_native_stream_terminates_after_calling_a_permanently_suppressed_t
|
||||
assert 'tools' not in requests[3]
|
||||
assert 'best concise final answer' in requests[3]['messages'][-1]['content'].lower()
|
||||
final = [event for event in events if event.get("type") == "final_response"]
|
||||
assert final == []
|
||||
metrics = next(event['data'] for event in events if event.get('type') == 'metrics')
|
||||
assert metrics['clean_v3_turn'][-1]['content'] == 'Final answer from existing evidence.'
|
||||
if ignores_final_instruction:
|
||||
assert len(final) == 1
|
||||
assert 'model repeated a tool call' in final[0]['content']
|
||||
assert metrics['clean_v3_turn'][-1]['content'] == final[0]['content']
|
||||
else:
|
||||
assert final == []
|
||||
assert metrics['clean_v3_turn'][-1]['content'] == 'Final answer from existing evidence.'
|
||||
assert raw[-1] == 'data: [DONE]\n\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -7284,3 +7402,275 @@ async def test_tool_round_leadin_is_replaced_by_terminal_synthesis(monkeypatch):
|
||||
"I'll browse IKEA and look at their chairs.",
|
||||
'The most epic option is the DYVLINGE swivel chair.',
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_denied_call_after_successful_evidence_recovers_instead_of_refusing(monkeypatch):
|
||||
"""A blocked call must not discard evidence an earlier call already produced."""
|
||||
import src.clean_agent_preview as module
|
||||
|
||||
def call(index, name, arguments):
|
||||
return {'index': index, 'id': f'call-{index}', 'function': {
|
||||
'name': name, 'arguments': json.dumps(arguments),
|
||||
}}
|
||||
|
||||
packets = iter([
|
||||
{'choices': [{'delta': {'tool_calls': [
|
||||
call(0, 'web_search', {'query': 'EEG headset prices'})]}}]},
|
||||
{'choices': [{'delta': {'tool_calls': [
|
||||
call(0, 'web_fetch', {'url': 'https://vendor.example/eeg'})]}}]},
|
||||
{'choices': [{'delta': {'content': (
|
||||
'Based on the search result, the EEG headset starts at $299 from the '
|
||||
'vendor. I could not open the vendor page because that operation was '
|
||||
'not permitted.'
|
||||
)}}]},
|
||||
])
|
||||
requests = []
|
||||
|
||||
class Response:
|
||||
def __init__(self, payload): self.payload = payload
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *args): pass
|
||||
def raise_for_status(self): pass
|
||||
async def aiter_lines(self):
|
||||
yield 'data: ' + json.dumps(self.payload)
|
||||
yield 'data: [DONE]'
|
||||
|
||||
class Client:
|
||||
def __init__(self, **kwargs): pass
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *args): pass
|
||||
def stream(self, *args, **kwargs):
|
||||
requests.append(kwargs['json'])
|
||||
return Response(next(packets))
|
||||
|
||||
async def execute(block, **kwargs):
|
||||
if block.tool_type == 'web_search':
|
||||
return 'web_search', {
|
||||
'output': '[1] EEG Headset Prices\n https://vendor.example/eeg',
|
||||
'exit_code': 0,
|
||||
'evidence_status': 'available',
|
||||
}
|
||||
return 'web_fetch', {
|
||||
'output': 'The exact operation is outside server request authority.',
|
||||
'exit_code': 1,
|
||||
'blocked': True,
|
||||
'failure_kind': 'request_authority_denied',
|
||||
}
|
||||
|
||||
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
|
||||
monkeypatch.setattr(module, 'execute_tool_block', execute)
|
||||
schemas = [
|
||||
schema for schema in FUNCTION_TOOL_SCHEMAS
|
||||
if schema['function']['name'] in {'web_search', 'web_fetch'}
|
||||
]
|
||||
contract = resolve_full_inventory_contract(schemas=schemas, policy=ToolPolicy())
|
||||
raw = [chunk async for chunk in stream_preview(
|
||||
endpoint_url='http://test', model='test',
|
||||
messages=[{'role': 'user', 'content': (
|
||||
'Search the web for the latest EEG headset prices and open the vendor page.'
|
||||
)}],
|
||||
headers={}, turn_contract=contract, session_id='test', owner='test',
|
||||
disabled_tools=set(), tool_policy=ToolPolicy(), max_rounds=5,
|
||||
)]
|
||||
events = [json.loads(chunk[6:]) for chunk in raw if '[DONE]' not in chunk]
|
||||
assert any(
|
||||
event.get('type') == 'completion_recovery'
|
||||
and event.get('reason') == 'denial_after_successful_evidence'
|
||||
for event in events
|
||||
)
|
||||
visible = ''.join(
|
||||
str(e.get('delta') or e.get('content') or '') for e in events
|
||||
)
|
||||
assert '$299' in visible
|
||||
assert denied_response() not in visible
|
||||
# The recovery round must synthesize without tools.
|
||||
assert requests[-1].get('tools') in (None, [])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('thinking_mode, expected_thinking', [
|
||||
('on', {'type': 'enabled'}),
|
||||
(None, {'type': 'enabled'}),
|
||||
('off', {'type': 'disabled'}),
|
||||
])
|
||||
async def test_deepseek_v4_preview_reserves_reasoning_budget_and_honors_toggle(
|
||||
monkeypatch, thinking_mode, expected_thinking,
|
||||
):
|
||||
import src.clean_agent_preview as module
|
||||
|
||||
requests = []
|
||||
|
||||
class Response:
|
||||
def __init__(self, payload): self.payload = payload
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *args): pass
|
||||
def raise_for_status(self): pass
|
||||
async def aiter_lines(self):
|
||||
yield 'data: ' + json.dumps(self.payload)
|
||||
yield 'data: [DONE]'
|
||||
|
||||
class Client:
|
||||
def __init__(self, **kwargs): pass
|
||||
async def __aenter__(self): return self
|
||||
async def __aexit__(self, *args): pass
|
||||
def stream(self, *args, **kwargs):
|
||||
requests.append(kwargs['json'])
|
||||
return Response({'choices': [{'delta': {
|
||||
'reasoning_content': 'Let me reason about this.',
|
||||
'content': 'The answer is 42.',
|
||||
}}]})
|
||||
|
||||
monkeypatch.setattr(module.httpx, 'AsyncClient', Client)
|
||||
contract = resolve_full_inventory_contract(schemas=[], policy=ToolPolicy())
|
||||
kwargs = {} if thinking_mode is None else {'thinking_mode': thinking_mode}
|
||||
raw = [chunk async for chunk in stream_preview(
|
||||
endpoint_url='https://api.deepseek.com/v1/chat/completions',
|
||||
model='deepseek-v4-flash',
|
||||
messages=[{'role': 'user', 'content': 'What is the answer?'}],
|
||||
headers={}, turn_contract=contract, session_id='test', owner='test',
|
||||
disabled_tools=set(), tool_policy=ToolPolicy(), **kwargs,
|
||||
)]
|
||||
|
||||
assert requests, 'a provider request should have been sent'
|
||||
sent = requests[0]
|
||||
if expected_thinking == {'type': 'disabled'}:
|
||||
assert sent.get('reasoning_effort') == 'none'
|
||||
else:
|
||||
# Reasoning-capable models must reserve budget above the 768 default.
|
||||
assert sent['max_tokens'] >= 4096
|
||||
assert sent.get('reasoning_effort')
|
||||
assert sent.get('thinking') == expected_thinking
|
||||
assert any('The answer is 42.' in chunk for chunk in raw)
|
||||
|
||||
|
||||
def test_workspace_process_denial_uses_an_honest_refusal(tmp_path):
|
||||
from src.clean_agent_preview import (
|
||||
workspace_process_denial,
|
||||
WORKSPACE_REQUIRED_REFUSAL,
|
||||
denied_response,
|
||||
)
|
||||
# A resource-identity / sealed-workspace denial is recognised from any of
|
||||
# the tool-event fields (error / output JSON / failure_kind).
|
||||
assert workspace_process_denial([
|
||||
{'tool': 'bash', 'exit_code': 1, 'blocked': True,
|
||||
'output': '{"error":"Process creation requires a sealed workspace and launch scope",'
|
||||
'"failure_kind":"resource_identity_denied"}'},
|
||||
])
|
||||
assert workspace_process_denial([{'failure_kind': 'resource_identity_denied'}])
|
||||
# A plain request-authority denial is not a workspace denial.
|
||||
assert not workspace_process_denial([
|
||||
{'tool': 'bash', 'blocked': True, 'failure_kind': 'request_authority_denied',
|
||||
'error': 'The exact operation is outside server request authority.'},
|
||||
])
|
||||
assert not workspace_process_denial([])
|
||||
assert 'workspace' in WORKSPACE_REQUIRED_REFUSAL.lower()
|
||||
assert WORKSPACE_REQUIRED_REFUSAL != denied_response()
|
||||
# The terminal-denial branch picks the workspace message when appropriate.
|
||||
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
|
||||
assert 'if workspace_process_denial(executions)' in source
|
||||
assert 'WORKSPACE_REQUIRED_REFUSAL' in source
|
||||
|
||||
|
||||
def test_spec_router_think_decision_and_reason(monkeypatch):
|
||||
import sys
|
||||
import types
|
||||
from src.clean_agent_preview import (
|
||||
spec_router_thinking_decision,
|
||||
spec_router_think_reason,
|
||||
)
|
||||
|
||||
# --- router disabled -> no decision, no reason (default OFF) -------------
|
||||
fake = types.ModuleType('src.spec_router')
|
||||
fake.think_router_enabled = lambda: False
|
||||
fake.route_thinking = lambda text: {'think': True, 'p_think': 0.9, 'ms': 1.0}
|
||||
monkeypatch.setitem(sys.modules, 'src.spec_router', fake)
|
||||
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
|
||||
assert spec_router_think_reason(1, None, '') == ''
|
||||
|
||||
# --- enabled but not a spec surface -> no decision ----------------------
|
||||
fake.think_router_enabled = lambda: True
|
||||
monkeypatch.setattr(
|
||||
'src.agent_loop._configured_model_tool_surface_raw',
|
||||
lambda *a, **k: 'native', raising=False,
|
||||
)
|
||||
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
|
||||
|
||||
# --- enabled on spec + think=True -> reason only on round 1 when no other
|
||||
monkeypatch.setattr(
|
||||
'src.agent_loop._configured_model_tool_surface_raw',
|
||||
lambda *a, **k: 'spec', raising=False,
|
||||
)
|
||||
decision = spec_router_thinking_decision('u', 'm', 'o', {}, 'plan the refactor')
|
||||
assert decision == {'think': True, 'p_think': 0.9, 'ms': 1.0}
|
||||
assert spec_router_think_reason(1, decision, '') == 'router_think'
|
||||
# existing rule wins; only round 1; think=False never fires
|
||||
assert spec_router_think_reason(1, decision, 'code') == ''
|
||||
assert spec_router_think_reason(2, decision, '') == ''
|
||||
assert spec_router_think_reason(1, {'think': False}, '') == ''
|
||||
|
||||
# --- route_thinking raises -> fail open (None), rules only --------------
|
||||
def _boom(text):
|
||||
raise RuntimeError('router down')
|
||||
fake.route_thinking = _boom
|
||||
assert spec_router_thinking_decision('u', 'm', 'o', {}, 'hi') is None
|
||||
assert spec_router_think_reason(1, None, '') == ''
|
||||
|
||||
# --- wiring present in stream_preview -----------------------------------
|
||||
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
|
||||
assert 'spec_router_thinking_decision(' in source
|
||||
assert 'spec_router_think_reason(' in source
|
||||
|
||||
|
||||
def test_qwen_think_split_streams_thinking_then_answer_without_buffering():
|
||||
from src.clean_agent_preview import qwen_think_split_events
|
||||
|
||||
state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
|
||||
events = []
|
||||
content = ''
|
||||
for piece in ['<think>', 'abc', '</think>', 'Hello']:
|
||||
content += piece
|
||||
events.extend(qwen_think_split_events(content, state))
|
||||
|
||||
thinking = ''.join(e['delta'] for e in events if e.get('thinking'))
|
||||
answer = ''.join(e['delta'] for e in events if not e.get('thinking'))
|
||||
assert thinking == 'abc'
|
||||
assert answer == 'Hello'
|
||||
# every emitted delta is non-empty and typed
|
||||
assert all(e.get('delta') for e in events)
|
||||
assert [e.get('thinking') for e in events] == [True, None]
|
||||
|
||||
# the stored answer stays identical to visible_content_after_qwen_thinking
|
||||
from src.clean_agent_preview import visible_content_after_qwen_thinking
|
||||
assert visible_content_after_qwen_thinking(content) == 'Hello'
|
||||
|
||||
|
||||
def test_qwen_think_split_holds_back_a_partial_close_tag():
|
||||
from src.clean_agent_preview import qwen_think_split_events
|
||||
|
||||
state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
|
||||
events = []
|
||||
content = ''
|
||||
for piece in ['<think>a', '</thi', 'nk>', 'B']:
|
||||
content += piece
|
||||
events.extend(qwen_think_split_events(content, state))
|
||||
|
||||
thinking = ''.join(e['delta'] for e in events if e.get('thinking'))
|
||||
answer = ''.join(e['delta'] for e in events if not e.get('thinking'))
|
||||
assert thinking == 'a' # the partial '</thi' is never leaked
|
||||
assert answer == 'B'
|
||||
assert all(e.get('delta') for e in events)
|
||||
|
||||
|
||||
def test_qwen_think_split_is_inert_without_a_think_block():
|
||||
from src.clean_agent_preview import qwen_think_split_events
|
||||
|
||||
state = {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
|
||||
assert qwen_think_split_events('plain answer', state) == []
|
||||
assert qwen_think_split_events('', state) == []
|
||||
assert state == {'think_emitted': 0, 'answer_emitted': 0, 'closed': False}
|
||||
|
||||
# source-level: stream_preview wires the incremental split
|
||||
source = (Path(__file__).resolve().parents[1] / 'src/clean_agent_preview.py').read_text()
|
||||
assert 'qwen_think_split_events(' in source
|
||||
assert "think_answer_streamed" in source
|
||||
|
||||
@@ -624,8 +624,7 @@ def test_bridge_post_targets_endpoint_paths():
|
||||
posted = {}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout=None, *, trust_env=True):
|
||||
assert trust_env is False
|
||||
def __init__(self, timeout=None):
|
||||
posted["timeout"] = timeout
|
||||
|
||||
async def __aenter__(self):
|
||||
@@ -696,8 +695,8 @@ def test_bridge_post_treats_empty_or_error_payload_as_failure():
|
||||
payloads = iter(({}, {"error": "token rejected"}))
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout=None, *, trust_env=True):
|
||||
assert trust_env is False
|
||||
def __init__(self, timeout=None):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -735,8 +734,8 @@ def test_bridge_post_http_or_payload_error_overrides_false_success_code():
|
||||
))
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout=None, *, trust_env=True):
|
||||
assert trust_env is False
|
||||
def __init__(self, timeout=None):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -770,8 +769,8 @@ def test_bridge_post_rejects_malformed_exit_code():
|
||||
return {"output": "looks successful", "exit_code": "zero"}
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout=None, *, trust_env=True):
|
||||
assert trust_env is False
|
||||
def __init__(self, timeout=None):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -805,8 +804,8 @@ def test_bridge_post_reports_empty_invalid_json_response():
|
||||
raise ValueError("not JSON")
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout=None, *, trust_env=True):
|
||||
assert trust_env is False
|
||||
def __init__(self, timeout=None):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
@@ -834,8 +833,8 @@ def test_bridge_post_reports_empty_invalid_json_response():
|
||||
def test_bridge_post_cancellation_schedules_host_operation_cancel():
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, timeout=None, *, trust_env=True):
|
||||
assert trust_env is False
|
||||
def __init__(self, timeout=None):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
@@ -61,20 +61,11 @@ def test_grep_ignore_case(repo):
|
||||
assert "b.txt:2:" in r["output"]
|
||||
|
||||
|
||||
def test_grep_glob_filter(repo, monkeypatch):
|
||||
def test_grep_glob_filter(repo):
|
||||
r = _run("grep", f'{{"pattern": "needle", "ignore_case": true, "glob": "*.py", "path": "{repo}"}}')
|
||||
assert r["exit_code"] == 0
|
||||
assert "a.py" in r["output"]
|
||||
assert "b.txt" not in r["output"]
|
||||
|
||||
# Positive control: verify that fallback path honors the exact same glob contract
|
||||
real_which = shutil.which
|
||||
monkeypatch.setattr(shutil, "which", lambda name, *a, **kw: None if name == "rg" else real_which(name, *a, **kw))
|
||||
r_fallback = _run("grep", f'{{"pattern": "needle", "ignore_case": true, "glob": "*.py", "path": "{repo}"}}')
|
||||
assert r_fallback["exit_code"] == 0
|
||||
assert "a.py" in r_fallback["output"]
|
||||
assert "b.txt" not in r_fallback["output"]
|
||||
|
||||
|
||||
def test_grep_no_match(repo):
|
||||
r = _run("grep", f'{{"pattern": "zzzznotfound", "path": "{repo}"}}')
|
||||
@@ -103,17 +94,6 @@ def test_grep_python_fallback_when_no_rg(repo, monkeypatch):
|
||||
assert ".git/config" not in r["output"]
|
||||
|
||||
|
||||
def test_grep_python_fallback_uses_relative_glob_paths(repo, monkeypatch):
|
||||
monkeypatch.setattr(shutil, "which", lambda name: None)
|
||||
r = _run(
|
||||
"grep",
|
||||
f'{{"pattern": "needle|python", "glob": "**/*.py", "path": "{repo}"}}',
|
||||
)
|
||||
assert r["exit_code"] == 0
|
||||
assert "a.py" in r["output"]
|
||||
assert "sub/deep/c.py" in r["output"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("rg") is None, reason="targets the ripgrep fast-path")
|
||||
def test_grep_skips_case_variant_sensitive_files_rg(repo):
|
||||
"""The rg fast-path must exclude deny-listed key files case-insensitively.
|
||||
|
||||
@@ -17,6 +17,42 @@ ERROR = 'event: error\ndata: {"status": 504, "error": {"message": "stream timeou
|
||||
DONE = 'data: [DONE]\n\n'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_answer_deltas_are_visible_before_terminal_completion_check():
|
||||
finish = asyncio.Event()
|
||||
|
||||
@with_completion_gate
|
||||
async def stream(messages):
|
||||
yield _event({'delta': 'Live answer starts. '})
|
||||
await finish.wait()
|
||||
yield _event({'type': 'metrics', 'data': {}})
|
||||
yield DONE
|
||||
|
||||
output = stream([])
|
||||
first = await asyncio.wait_for(anext(output), timeout=0.2)
|
||||
assert json.loads(first[6:])['delta'] == 'Live answer starts. '
|
||||
finish.set()
|
||||
remaining = [chunk async for chunk in output]
|
||||
assert _decision([first, *remaining])['can_complete'] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_completion_replacement_replaces_already_streamed_answer_for_turn():
|
||||
@with_completion_gate
|
||||
async def stream(messages):
|
||||
yield _event({'delta': 'The function returns a boolean. I ran pytest and all tests passed.'})
|
||||
yield _event({'type': 'metrics', 'data': {}})
|
||||
yield DONE
|
||||
|
||||
chunks = [chunk async for chunk in stream([{'role': 'user', 'content': 'Explain this code.'}])]
|
||||
frames = [data for event, data in _frames(chunks) if event == 'message' and isinstance(data, dict)]
|
||||
assert any(item.get('delta') == 'The function returns a boolean.' for item in frames)
|
||||
assert not any('I ran pytest' in str(item.get('delta', '')) for item in frames)
|
||||
replacement = next(item for item in frames if item.get('type') == 'final_response')
|
||||
assert 'I ran pytest' not in replacement['content']
|
||||
assert replacement['replacement_scope'] == 'turn'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('terminal_texts', [[], ['', ''], ['Recovered answer with literal [DONE] text.']])
|
||||
async def test_terminal_round_retraction_does_not_resurrect_buffered_drafts(terminal_texts):
|
||||
|
||||
@@ -338,8 +338,8 @@ def test_hardlinked_effect_state_is_control_plane_without_scanning_the_store(tmp
|
||||
assert str(store) not in listed
|
||||
assert resources._control_plane_path(str(alias), snapshot=snapshot) is True
|
||||
assert str(store) not in globbed, "the effect store is listed one level, never recursively inventoried"
|
||||
# Multi-linked regular files are treated as control-plane resources to prevent hardlink race/escape behavior.
|
||||
# Multiply linked files elsewhere stay ordinary.
|
||||
elsewhere = tmp_path / "other.txt"
|
||||
elsewhere.write_text("y")
|
||||
os.link(elsewhere, workspace / "pnpm-style.txt")
|
||||
assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is True
|
||||
assert resources._control_plane_path(str(workspace / "pnpm-style.txt")) is False
|
||||
|
||||
@@ -179,16 +179,8 @@ def test_cancelled_write_unsettles_an_earlier_success(run, ws, monkeypatch):
|
||||
|
||||
def test_mid_write_failure_unsettles_but_refusal_preserves(run, ws, monkeypatch):
|
||||
run("write_file", {"path": "a.txt", "content": "hello\n"})
|
||||
# Explicit empty JSON clear/write is authoritative and succeeds.
|
||||
run("write_file", {"path": "a.txt", "content": ""})
|
||||
assert run.journal.effects.assessments()[-1].execution is fx.ExecutionOutcome.REPORTED_SUCCESS
|
||||
|
||||
# Re-seed the artifact content
|
||||
run("write_file", {"path": "a.txt", "content": "hello\n"})
|
||||
assert ledger(run.journal, ws).evaluate().can_complete
|
||||
|
||||
# A deterministic refusal before the mutation stage keeps the artifact.
|
||||
run("write_file", "a.txt\n")
|
||||
run("write_file", {"path": "a.txt", "content": ""})
|
||||
assert run.journal.effects.assessments()[-1].execution is fx.ExecutionOutcome.FAILED
|
||||
assert ledger(run.journal, ws).evaluate().can_complete
|
||||
|
||||
|
||||
@@ -167,56 +167,10 @@ def test_renders_one_table_row_per_variable(generator, fake_tree, monkeypatch):
|
||||
page = generator.render(found, generator.naive_line_scan(fake_tree))
|
||||
|
||||
assert page.startswith("---\nlayout: default\n---\n")
|
||||
assert "| `ODYSSEUS_VIA_HELPER` | `5 * 1024` | `src/indirect.py` | A synthetic limit. |" in page
|
||||
assert "| `ODYSSEUS_VIA_HELPER` | `5 * 1024` | `src/indirect.py:16` | A synthetic limit. |" in page
|
||||
assert "### Search" in page
|
||||
|
||||
|
||||
def _render_one(generator, root, name, monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
generator.VARIABLE_NOTES, name, ("Search", generator.USER, "A synthetic flag.")
|
||||
)
|
||||
return generator.render(
|
||||
{name: generator.collect(root)[name]}, generator.naive_line_scan(root)
|
||||
)
|
||||
|
||||
|
||||
def test_page_does_not_change_when_an_unrelated_edit_shifts_lines(
|
||||
generator, fake_tree, monkeypatch
|
||||
):
|
||||
"""An edit that moves a read down a file must not make the page stale.
|
||||
|
||||
The page used to cite `path:line`, so any PR adding a line above a read had
|
||||
to regenerate it and then conflicted with every other PR that had.
|
||||
"""
|
||||
before = _render_one(generator, fake_tree, "ODYSSEUS_VIA_HELPER", monkeypatch)
|
||||
source = fake_tree / "src" / "indirect.py"
|
||||
source.write_text("import sys\n\n\n" + source.read_text(encoding="utf-8"), encoding="utf-8")
|
||||
|
||||
after = _render_one(generator, fake_tree, "ODYSSEUS_VIA_HELPER", monkeypatch)
|
||||
|
||||
assert generator.collect(fake_tree)["ODYSSEUS_VIA_HELPER"].primary.lineno == 19
|
||||
assert after == before
|
||||
|
||||
|
||||
def test_extra_reads_are_counted_per_file(generator, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(generator, "SOURCE_ROOTS", ("app.py", "src"))
|
||||
_write(tmp_path, "app.py", """
|
||||
import os
|
||||
|
||||
FIRST = os.environ.get("ODYSSEUS_READ_OFTEN", "1")
|
||||
SECOND = os.environ.get("ODYSSEUS_READ_OFTEN", "1")
|
||||
""")
|
||||
_write(tmp_path, "src/elsewhere.py", """
|
||||
import os
|
||||
|
||||
AGAIN = os.getenv("ODYSSEUS_READ_OFTEN")
|
||||
""")
|
||||
|
||||
page = _render_one(generator, tmp_path, "ODYSSEUS_READ_OFTEN", monkeypatch)
|
||||
|
||||
assert "| `app.py` (+1 more) |" in page
|
||||
|
||||
|
||||
def test_every_variable_read_in_the_repository_is_documented(built):
|
||||
_, variables, problems = built
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
import logging
|
||||
import src.tool_execution as tool_execution
|
||||
from tests.runtime_evidence_helpers import server_authorized_executor
|
||||
@@ -17,60 +15,6 @@ from src.tool_execution import (
|
||||
execute_tool_block = server_authorized_executor(execute_tool_block)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", ['{"path":"/workspace/x"}', '{"path":"/workspace/x","content":null}',
|
||||
'/workspace/x', '/workspace/x\n', '/workspace/x\n \t',
|
||||
json.dumps({'path': '/workspace/x.py', 'content': '```python\n\n```'})])
|
||||
def test_bridge_refuses_missing_or_ambiguous_empty_content(monkeypatch, tmp_path, content):
|
||||
async def forbidden(*args, **kwargs):
|
||||
raise AssertionError("ambiguous write reached remote producer")
|
||||
monkeypatch.setattr(tool_execution, "_client_bridge", lambda context: {"url": "http://bridge", "token": "x"})
|
||||
monkeypatch.setattr(tool_execution, "_bridge_post", forbidden)
|
||||
async def invoke():
|
||||
token = tool_execution._active_workspace.set(str(tmp_path))
|
||||
try:
|
||||
return await tool_execution._route_tool_via_bridge("write_file", content, "s", {})
|
||||
finally:
|
||||
tool_execution._active_workspace.reset(token)
|
||||
_, result = asyncio.run(invoke())
|
||||
assert result["exit_code"] == 1
|
||||
assert "content" in result["error"] or "JSON" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", ["", " \t", "normal source"])
|
||||
def test_bridge_honors_explicit_json_string_content(monkeypatch, tmp_path, body):
|
||||
import base64
|
||||
seen = []
|
||||
async def post(bridge, endpoint, payload, **kwargs):
|
||||
seen.append((endpoint, base64.b64decode(payload["content_b64"])))
|
||||
return {"output": "written", "exit_code": 0}
|
||||
monkeypatch.setattr(tool_execution, "_client_bridge", lambda context: {"url": "http://bridge", "token": "x"})
|
||||
monkeypatch.setattr(tool_execution, "_bridge_post", post)
|
||||
async def invoke():
|
||||
token = tool_execution._active_workspace.set(str(tmp_path))
|
||||
try:
|
||||
return await tool_execution._route_tool_via_bridge("write_file", json.dumps({"path": "/workspace/x", "content": body}), "s", {})
|
||||
finally:
|
||||
tool_execution._active_workspace.reset(token)
|
||||
_, result = asyncio.run(invoke())
|
||||
assert result["exit_code"] == 0
|
||||
assert seen == [("/write", body.encode())]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", ['/workspace/x\n', '{"path":"/workspace/x"}', '/workspace/x\n \t'])
|
||||
def test_scoped_bridge_cannot_blindly_write_implicit_empty(content):
|
||||
async def forbidden(*args):
|
||||
raise AssertionError("scoped bridge received an ambiguous destructive write")
|
||||
bridge = AgentExecutionBridge(forbidden, frozenset({'write_file'}), 'empty-intent-test')
|
||||
async def invoke():
|
||||
with bind_execution_bridge(bridge):
|
||||
block = Block(content)
|
||||
block.tool_type = 'write_file'
|
||||
return await execute_tool_block(block, security_context=NO_TOOL_SECURITY_CONTEXT)
|
||||
_, result = asyncio.run(invoke())
|
||||
assert result['exit_code'] == 1
|
||||
assert 'content' in result['error']
|
||||
|
||||
|
||||
class Block:
|
||||
tool_type = "host_shell"
|
||||
|
||||
@@ -215,7 +159,7 @@ def test_scoped_execution_bridge_failure_logs_compact_warning(caplog) -> None:
|
||||
assert "Scoped execution bridge test-environment failed for tool=host_shell" in caplog.text
|
||||
|
||||
|
||||
def test_tui_bridge_write_file_rejects_text_for_binary_artifact(monkeypatch, tmp_path) -> None:
|
||||
def test_tui_bridge_write_file_rejects_text_for_binary_artifact(monkeypatch) -> None:
|
||||
"""The bridge path must preserve rendered media just like local writes."""
|
||||
called = False
|
||||
|
||||
@@ -228,17 +172,12 @@ def test_tui_bridge_write_file_rejects_text_for_binary_artifact(monkeypatch, tmp
|
||||
monkeypatch.setattr(tool_execution, "_bridge_post", fake_post)
|
||||
|
||||
async def invoke():
|
||||
token = tool_execution._active_workspace.set(str(tmp_path))
|
||||
try:
|
||||
return await tool_execution._route_tool_via_bridge(
|
||||
"write_file",
|
||||
'{"path":"/tmp_workspace/results/screenshot.png","content":"The screenshot is complete."}',
|
||||
"run-1",
|
||||
{"surface": "odysseus-tui"},
|
||||
)
|
||||
|
||||
finally:
|
||||
tool_execution._active_workspace.reset(token)
|
||||
return await tool_execution._route_tool_via_bridge(
|
||||
"write_file",
|
||||
'{"path":"/tmp_workspace/results/screenshot.png","content":"The screenshot is complete."}',
|
||||
"run-1",
|
||||
{"surface": "odysseus-tui"},
|
||||
)
|
||||
|
||||
description, result = asyncio.run(invoke())
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ def _chat_stream_endpoint(
|
||||
endpoint_url="https://selected.example/v1",
|
||||
session_model="selected-model",
|
||||
session_history=(),
|
||||
attachment_meta=(),
|
||||
):
|
||||
def add_message(message):
|
||||
captured.setdefault("added_messages", []).append(message)
|
||||
@@ -172,7 +173,7 @@ def _chat_stream_endpoint(
|
||||
{"role": "assistant", "content": "old answer"},
|
||||
{"role": "user", "content": "hello"},
|
||||
],
|
||||
preprocessed=SimpleNamespace(attachment_meta=[]),
|
||||
preprocessed=SimpleNamespace(attachment_meta=list(attachment_meta)),
|
||||
auto_opened_docs=[],
|
||||
rag_sources=[],
|
||||
web_sources=[],
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
"""Invalid truncation requests must leave saved chat history intact."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from tests.helpers.database import isolated_session_database
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def history_client(tmp_path, monkeypatch):
|
||||
from core.models import ChatMessage
|
||||
from routes.history import history_routes
|
||||
from routes import session_routes
|
||||
|
||||
with isolated_session_database(tmp_path) as (manager, database):
|
||||
monkeypatch.setattr(history_routes, "SessionLocal", database.SessionLocal)
|
||||
monkeypatch.setattr(session_routes, "SessionLocal", database.SessionLocal)
|
||||
monkeypatch.setenv("AUTH_ENABLED", "true")
|
||||
manager.create_session("truncate-test", "Test", "http://example.test", "test", owner="alice")
|
||||
for text in ("first", "second", "third"):
|
||||
manager.add_message("truncate-test", ChatMessage("user", text))
|
||||
app = FastAPI()
|
||||
app.state.auth_manager = SimpleNamespace(is_configured=True)
|
||||
|
||||
@app.middleware("http")
|
||||
async def owner(request, call_next):
|
||||
request.state.current_user = request.headers.get("X-Test-Owner", "alice")
|
||||
return await call_next(request)
|
||||
|
||||
app.include_router(history_routes.setup_history_routes(manager))
|
||||
with TestClient(app, raise_server_exceptions=False) as client:
|
||||
yield client, manager, database
|
||||
|
||||
|
||||
def saved_messages(database):
|
||||
with database.SessionLocal() as db:
|
||||
return [row.content for row in db.query(database.ChatMessage).filter(
|
||||
database.ChatMessage.session_id == "truncate-test"
|
||||
).order_by(database.ChatMessage.timestamp).all()]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [
|
||||
{}, {"unrelated": 1}, {"before_msg_id": ""},
|
||||
{"keep_count": None}, {"keep_count": False}, {"keep_count": True},
|
||||
{"keep_count": 1.9}, {"keep_count": -1}, {"keep_count": "bad"},
|
||||
{"keep_count": []}, {"keep_count": {}},
|
||||
{"before_msg_id": True}, {"before_msg_id": ["message"]},
|
||||
{"before_msg_id": False}, {"before_msg_id": 0}, {"message_id": 0},
|
||||
[], None, "two", 2, False,
|
||||
])
|
||||
def test_invalid_target_preserves_history(history_client, payload):
|
||||
client, manager, database = history_client
|
||||
import json
|
||||
response = client.post("/api/session/truncate-test/truncate", content=json.dumps(payload), headers={"Content-Type": "application/json"})
|
||||
assert response.status_code == 400, response.text
|
||||
assert saved_messages(database) == ["first", "second", "third"]
|
||||
assert [m.content for m in manager.get_session("truncate-test").history] == ["first", "second", "third"]
|
||||
|
||||
|
||||
def test_malformed_json_preserves_history(history_client):
|
||||
client, _, database = history_client
|
||||
response = client.post("/api/session/truncate-test/truncate", content="{", headers={"Content-Type": "application/json"})
|
||||
assert response.status_code == 400, response.text
|
||||
assert saved_messages(database) == ["first", "second", "third"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("count,expected", [(0, []), (2, ["first", "second"]), (9, ["first", "second", "third"]), ("2", ["first", "second"])])
|
||||
def test_explicit_count_remains_supported(history_client, count, expected):
|
||||
client, _, database = history_client
|
||||
response = client.post("/api/session/truncate-test/truncate", json={"keep_count": count})
|
||||
assert response.status_code == 200, response.text
|
||||
assert saved_messages(database) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["before_msg_id", "message_id"])
|
||||
def test_message_id_boundary_remains_supported(history_client, field):
|
||||
client, _, database = history_client
|
||||
with database.SessionLocal() as db:
|
||||
message_id = db.query(database.ChatMessage).filter(database.ChatMessage.content == "second").one().id
|
||||
response = client.post("/api/session/truncate-test/truncate", json={field: message_id})
|
||||
assert response.status_code == 200, response.text
|
||||
assert saved_messages(database) == ["first"]
|
||||
|
||||
|
||||
def test_missing_message_id_does_not_clear_history(history_client):
|
||||
client, _, database = history_client
|
||||
response = client.post("/api/session/truncate-test/truncate", json={"before_msg_id": "missing"})
|
||||
assert response.status_code == 404
|
||||
assert saved_messages(database) == ["first", "second", "third"]
|
||||
|
||||
|
||||
def test_owner_gate_still_precedes_validation(history_client):
|
||||
client, _, database = history_client
|
||||
response = client.post("/api/session/truncate-test/truncate", json={}, headers={"X-Test-Owner": "bob"})
|
||||
assert response.status_code == 404
|
||||
assert saved_messages(database) == ["first", "second", "third"]
|
||||
@@ -7,6 +7,7 @@ os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
|
||||
import pytest
|
||||
|
||||
from src.llm_core import _supports_thinking
|
||||
from src.model_profiles import supports_user_thinking_toggle
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -25,3 +26,16 @@ def test_deepseek_v4_models_support_thinking(model):
|
||||
@pytest.mark.parametrize("model", ["deepseek-v3", "deepseek-chat"])
|
||||
def test_other_deepseek_models_are_not_promoted_to_thinking(model):
|
||||
assert _supports_thinking(model) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"model",
|
||||
[
|
||||
"deepseek-v4",
|
||||
"deepseek-v4-flash",
|
||||
"DeepSeek-V4-Flash",
|
||||
"deepseek/deepseek-v4-flash",
|
||||
],
|
||||
)
|
||||
def test_deepseek_v4_models_expose_the_user_thinking_toggle(model):
|
||||
assert supports_user_thinking_toggle(model) is True
|
||||
|
||||
@@ -368,7 +368,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
|
||||
|
||||
mdToHtml defers math to a document-scoped flush, which cannot reach a
|
||||
detached node, so the export has to typeset its own container before
|
||||
handing it to browser printing. This is that container: pending spans in, real
|
||||
handing it to html2pdf. This is that container: pending spans in, real
|
||||
KaTeX markup out, no .katex-error and nothing left pending.
|
||||
"""
|
||||
out = _run_node(
|
||||
@@ -400,7 +400,7 @@ def test_detached_container_math_typesets_with_the_real_renderer(node_available)
|
||||
assert "ody-math-pending" not in out["written"]
|
||||
|
||||
|
||||
def test_pdf_export_typesets_its_container_before_print():
|
||||
def test_pdf_export_typesets_its_container_before_html2pdf():
|
||||
"""Ordering in a call site, so pin the call site. No node needed."""
|
||||
source = document_source()
|
||||
match = re.search(r"\n async function exportAsPdf\(\) \{(.*?)\n \}\n", source, re.S)
|
||||
@@ -410,7 +410,7 @@ def test_pdf_export_typesets_its_container_before_print():
|
||||
render = "await markdownModule.renderMath(container);"
|
||||
assert render in body, "the export never typesets its detached container"
|
||||
assert body.index("container.innerHTML = html;") < body.index(render)
|
||||
assert body.index(render) < body.index("frame.contentWindow.print()")
|
||||
assert body.index(render) < body.index("window.html2pdf()")
|
||||
|
||||
|
||||
def test_md_to_html_renders_inline_once_katex_is_loaded(node_available):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user