Compare commits

...
Author SHA1 Message Date
pewdiepie-archdaemonandClaude Opus 5.5 9bdea753ae fix(harness): ship coherent vendor harness cluster
Rebuild PR C to ship the vendor/photocraft harness as a coherent unit
(vendor implementation + vendor tests) instead of a per-file ahead/behind
mix that produced broken imports and 6+ test failures.

- Flip 36 harness impl/route/test files back to vendor (the per-file
  line-set policy misclassified user-rewritten files as 'behind').
- Flip core/models.py + src/tool_approval_scopes.py to vendor: vendor's
  harness (authority.py, turn_contract.py, clean_agent_preview.py) depends
  on vendor's older approval-scope API; upstream added
  CHAT_SESSION_APPROVAL_SIGNATURE_FIELD that vendor's core/models.py lacks.
- Add src/shutdown.py (new vendor infra) required by routes/chat_routes.py.
- Keep src/agent_tools/__init__.py at upstream (editor_tools registration
  lives in PR B).

Upstream-only security divergence preserved in C_upstream_delta.patch and
flagged for integration-time reconciliation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:59:04 +00:00
pewdiepie-archdaemonandClaude Opus 5.5 364229f2dc feat(harness): authority, turn contract, tools, clean_agent_preview
Chat/agent harness work: request-authority model, turn contract, tool
policy/parsing/execution, clean_agent_preview streaming/thinking, ask_user
persistence, session probes, and scheduler.

Source: vendor/photocraft (ad1cff44) harness files, 3-way merged onto
upstream/dev (a8c147b2). Harness core files were independently developed
in both lineages; resolution policy documented in pr_work/C_conflicts.md
(vendor kept where ahead, upstream kept where vendor is behind; full delta
preserved in pr_work/C_upstream_delta.patch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 13:41:53 +00:00
Robert Vind-Gardoș a8c147b238 fix(reminders): retry failed note delivery through the configured channel (#6566) 2026-10-07 15:38:41 +02:00
Léo ec5bcd2b9a perf(tools): take one control-plane snapshot per grep/glob/ls scan (#6551)
The #6503 merge added _control_plane_path() to _is_denied_tool_path(),
which grep, glob and ls call per enumerated entry. Without a snapshot
argument every call rebuilt _control_plane_snapshot() and stat'ed every
protected state file again. A 5,000-file grep went from 0.75 s to ~7 s,
and around 20k files it hits the grep timeout.

Thread an optional snapshot through _is_denied_tool_path() and
_can_traverse_tool_path(), and build it once per scan, the way
process_resources already does for its launch-boundary walk. Root
resolution and bound-resource resolution still observe fresh state.
2026-10-07 12:57:40 +01:00
Léo 137daab16e fix(preview): return validator-authored tool errors to the model (#6554)
The compact preview reduces every pre-execution tool error to a fixed
allowlist so exception text cannot reach the client. Messages our own
validators build per call are not on that list, so the model got "The
tool call could not be validated..." instead of the pdf_extract path
hint, the email uid provenance error, the artifact-Python target
message, or which schema argument was wrong. That is the default path
for the tool-profile models, Qwen3.5-9B included.

Each validator site now records its message in a per-call
validator_error before raising, and only that text is returned
verbatim. A schema failure gets a hint rebuilt from the offered schema
and the call's own arguments (missing property, expected type, allowed
values), never from the caught ValidationError. Everything else,
including all execution failures, still goes through
_public_preview_tool_error.
2026-10-07 12:52:39 +01:00
Léo 41cace1337 test(confinement): give user-content carve-out cases stable ids (#6555)
test_allows_user_content_the_app_hands_to_the_model is parametrized on
UPLOAD_DIR and friends, which sit under the per-worker data dir. pytest
used those paths as test ids, so every xdist worker collected different
names and `pytest -n 4` (documented in tests/README.md) aborted at
collection. CI shards with --shard and never saw it.
2026-10-07 12:51:58 +01:00
Léo 10377f0c95 ci: cut per-shard setup time in the pytest matrix (#6557)
Each of the four pytest shards spends ~84 s on setup before pytest
starts, and repeats all of it: pip resolves and installs ~108 packages
(~24 s, even with the pip cache hit), Playwright downloads Chromium and
the headless shell with no cache (~11 s), and three separate
apt-get update calls run.

- Install requirements with uv (astral-sh/setup-uv, pinned) and cache
  its downloads keyed on requirements.txt. uv resolves the same 108
  pins as pip for this requirements.txt.
- Cache ~/.cache/ms-playwright keyed on package-lock.json so a hit
  skips the browser downloads; --with-deps still installs system libs.
- Install FFmpeg and bubblewrap in one apt pass. The containment step
  keeps its bwrap probes unchanged.
- Add --durations=25 so the slowest tests per shard are visible; the
  shard planner only weights the slow marker, and shard times vary
  from 154 s to 296 s within one run.
2026-10-07 13:07:40 +02:00
Alexandre Teixeira 2c8e00ca05 fix(publication): restore project-origin branding and demo assets (#6561)
* chore(publication): restore cleared first-party assets

* fix(branding): restore publication asset integrations

* fix(publication): tighten restoration provenance
2026-10-07 02:08:34 +01:00
126 changed files with 4139 additions and 3728 deletions
+3 -3
View File
@@ -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
+22 -7
View File
@@ -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"
+2
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/photocraft"]
path = vendor/photocraft
url = https://github.com/storytold/photocraft
+19 -13
View File
@@ -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
View File
@@ -1 +1 @@
0.20.19
0.20.84
+1 -1
View File
@@ -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={},
+21
View File
@@ -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.
+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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/>
-10
View File
@@ -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
View File
@@ -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"}
+1 -8
View File
@@ -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
+4 -4
View File
@@ -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
+3 -9
View File
@@ -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
View File
@@ -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:])
+7 -11
View File
@@ -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):
+4 -8
View File
@@ -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,
@@ -436,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)
+15 -3
View File
@@ -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"
+4 -17
View File
@@ -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">
+8 -57
View File
@@ -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,
}
+4 -3
View File
@@ -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
View File
@@ -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.")
+8 -24
View File
@@ -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
+3 -8
View File
@@ -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
View File
@@ -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.
+57
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+43 -7
View File
@@ -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))
+70 -11
View File
@@ -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,
}
+6 -9
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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:
+7 -16
View File
@@ -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},
)
+5 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+6 -3
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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
View File
@@ -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:
+4 -4
View File
@@ -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],
+1 -1
View File
@@ -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
View File
@@ -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 = (
+7
View File
@@ -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).
+34
View File
@@ -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
View File
@@ -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.
-125
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")
+1
View File
@@ -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

+1
View File
@@ -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(){
+1
View File
@@ -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() {
+6 -1
View File
@@ -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
View File
@@ -7,7 +7,7 @@
// - Other static assets (images/fonts/libs): cache-first with bg refresh.
// - API / non-GET: never cached.
// Bump CACHE_NAME whenever the precache list or SW logic changes.
const CACHE_NAME = 'odysseus-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;
+369
View File
@@ -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
+45
View File
@@ -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();
}
+58
View File
@@ -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();
}
+41 -7
View File
@@ -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.
+1 -1
View File
@@ -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
+1
View File
@@ -20,3 +20,4 @@ def test_terse_timed_calendar_create_promotes_to_calendar():
assert intent.needs_tools
assert intent.category == "calendar"
+65
View File
@@ -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 == []
+1 -2
View File
@@ -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)
+38 -1
View File
@@ -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."""
+85
View File
@@ -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")
+1
View File
@@ -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",
+31
View File
@@ -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
+42
View File
@@ -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"})
+2 -3
View File
@@ -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():
+20
View File
@@ -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.'}
+12 -34
View File
@@ -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
+37
View File
@@ -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"
)
+424 -34
View File
@@ -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
+11 -12
View File
@@ -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
+1 -21
View File
@@ -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.
+36
View File
@@ -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):
+2 -2
View File
@@ -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
+1 -9
View File
@@ -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
+1 -47
View File
@@ -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
+7 -68
View File
@@ -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())
+2 -1
View File
@@ -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=[],
+14
View File
@@ -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
+3 -3
View File
@@ -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):
+145
View File
@@ -0,0 +1,145 @@
"""Model-picker backend: a slow/dead endpoint must not stall /api/models refresh.
Covers the fixes in routes/model_routes.py:
- the local endpoint refresh timeout default is 2s (was 10s);
- a recently failed/timed-out endpoint keeps a ~60s cooldown even on a
user-forced refresh, so a forced refresh does not re-probe a dead endpoint
on every picker open;
- probing is parallel, so one slow endpoint does not push the whole refresh
past its own timeout.
"""
import json
import sys
import time
import types
from types import SimpleNamespace
from unittest.mock import MagicMock
from tests.helpers.import_state import (
clear_fake_endpoint_resolver_modules,
preserve_import_state,
)
def _load_model_routes():
with preserve_import_state(
"core.database", "src.database", "core.session_manager", "routes.model_routes"
):
clear_fake_endpoint_resolver_modules()
if "core.database" not in sys.modules:
stub = types.ModuleType("core.database")
for name in (
"SessionLocal", "ModelEndpoint", "Session", "ChatMessage", "Document",
"DocumentVersion", "GalleryImage", "GalleryAlbum", "Note", "CalendarCal",
"CalendarEvent", "ScheduledTask", "TaskRun", "McpServer",
"ProviderAuthSession", "Base",
):
setattr(stub, name, MagicMock())
stub.utcnow_naive = MagicMock()
sys.modules["core.database"] = stub
import routes.model_routes as model_routes
return model_routes
def _endpoint(**kw):
base = dict(
id="ep", base_url="http://127.0.0.1:9/v1", api_key="k",
provider_auth_id=None, cached_models="[]", pinned_models=[],
is_enabled=True, model_refresh_mode="auto",
)
base.update(kw)
return SimpleNamespace(**base)
def test_local_refresh_timeout_is_two_seconds():
model_routes = _load_model_routes()
ep = _endpoint(base_url="http://100.67.207.85:8071/v1")
assert model_routes._endpoint_refresh_timeout(ep, "local") == 2.0
assert model_routes._endpoint_refresh_timeout(ep, "remote") == 2.0
def test_recent_failure_is_not_reprobed_even_when_forced():
model_routes = _load_model_routes()
router = model_routes.setup_model_routes(model_discovery=None)
should = router._should_refresh_endpoint
ep = _endpoint(id="remote-dead", base_url="http://100.67.207.85:8071/v1")
now = time.time()
ok, info = should(ep, now, force=True)
assert ok is True and info.get("base")
key = info["key"]
# A failure one second ago keeps a 60s cooldown even on a forced refresh.
router._refresh_state[key] = {
"fail_count": 1, "last_failure": now - 1.0, "inflight": False,
}
ok_recent, _ = should(ep, time.time(), force=True)
assert ok_recent is False
# A failure long past the 60s floor is re-probed again.
router._refresh_state[key] = {
"fail_count": 1, "last_failure": now - 120.0, "inflight": False,
}
ok_old, _ = should(ep, time.time(), force=True)
assert ok_old is True
class _FakeQuery:
def __init__(self, rows, first_value):
self._rows = rows
self._first = first_value
def filter(self, *args, **kwargs):
return self
def all(self):
return list(self._rows)
def first(self):
return self._first
class _FakeDB:
def __init__(self, rows, first_value):
self._rows = rows
self._first = first_value
self.committed = False
def query(self, model):
return _FakeQuery(self._rows, self._first)
def commit(self):
self.committed = True
def close(self):
pass
def test_one_slow_endpoint_does_not_stall_the_forced_refresh(monkeypatch):
model_routes = _load_model_routes()
router = model_routes.setup_model_routes(model_discovery=None)
healthy = _endpoint(id="healthy", base_url="http://127.0.0.1:7021/v1")
dead = _endpoint(id="dead", base_url="http://127.0.0.1:9/v1")
monkeypatch.setattr(model_routes, "SessionLocal", lambda: _FakeDB([healthy, dead], healthy))
monkeypatch.setattr(
model_routes, "_disable_stale_cookbook_local_endpoints", lambda db: False
)
def _fake_probe(base, api_key=None, timeout=5):
if "127.0.0.1:9" in base:
time.sleep(min(float(timeout or 2), 2.0))
raise RuntimeError("timed out")
return ["healthy-model"]
monkeypatch.setattr(model_routes, "_probe_endpoint", _fake_probe)
started = time.time()
done = router._refresh_caches_bg(force=True)
assert done is not None
assert done.wait(5) is True
elapsed = time.time() - started
assert elapsed <= 3.0, f"forced refresh took {elapsed:.2f}s"
assert json.loads(healthy.cached_models) == ["healthy-model"]

Some files were not shown because too many files have changed in this diff Show More