mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-09 10:39:11 +02:00
Merge 90555b819c into cf4e240ad1
This commit is contained in:
commit
30cc95824e
50 changed files with 3052 additions and 16 deletions
45
Dockerfile
45
Dockerfile
|
|
@ -16,18 +16,21 @@ FROM python:3.14-slim
|
|||
# downloads, and serves from Docker installs.
|
||||
# git/cmake are required when Cookbook builds llama.cpp on first llama.cpp
|
||||
# launch inside Docker.
|
||||
# nodejs/npm provide npx for the built-in Browser MCP server.
|
||||
# chromium provides the actual browser binary used by that MCP server.
|
||||
# nodejs/npm provide npx for the optional built-in Browser MCP server.
|
||||
# Google Chrome is its required browser channel; the MCP's headless mode still
|
||||
# launches this installed browser binary as the non-root app user.
|
||||
# gosu lets the entrypoint drop privileges cleanly so signals still reach
|
||||
# uvicorn directly (no extra shell layer like `su`/`sudo` would add).
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
cmake \
|
||||
curl \
|
||||
git \
|
||||
gnupg \
|
||||
nodejs \
|
||||
npm \
|
||||
chromium \
|
||||
ripgrep \
|
||||
tmux \
|
||||
openssh-client \
|
||||
gosu \
|
||||
|
|
@ -37,6 +40,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||
libmagic1 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install the Google Chrome channel that @playwright/mcp uses by default. Keep
|
||||
# the repository key isolated in /etc/apt/keyrings and install before the app
|
||||
# source so browser provisioning remains a reusable image layer. This is a
|
||||
# known-good Debian package lock, not the moving google-chrome-stable head.
|
||||
ARG GOOGLE_CHROME_VERSION=151.0.7922.108-1
|
||||
RUN install -m 0755 -d /etc/apt/keyrings \
|
||||
&& curl -fsSL https://dl.google.com/linux/linux_signing_key.pub \
|
||||
| gpg --dearmor -o /etc/apt/keyrings/google-chrome.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/google-chrome.gpg] http://dl.google.com/linux/chrome/deb/ stable main" \
|
||||
> /etc/apt/sources.list.d/google-chrome.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends google-chrome-stable=${GOOGLE_CHROME_VERSION} \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# libgl1/libglib2.0-0t64/libxcb1 are runtime shared libs (libGL.so.1,
|
||||
# libglib-2.0/libgthread, libxcb.so.1) that opencv-python (cv2) loads. The
|
||||
# slim base omits them, so the Cookbook "install realesrgan" path imports cv2
|
||||
|
|
@ -92,7 +109,27 @@ COPY --from=realesrgan-wheels /wheels/ /tmp/odysseus-wheels/
|
|||
RUN pip install --no-cache-dir --no-deps /tmp/odysseus-wheels/*.whl \
|
||||
&& rm -rf /tmp/odysseus-wheels
|
||||
|
||||
# Copy app code
|
||||
# Prewarm Browser MCP before copying application sources. This preserves the
|
||||
# existing pinned browser layer when application or LSP runtime code changes,
|
||||
# so an LSP-only rebuild does not fetch an unrelated package.
|
||||
RUN groupadd --gid 1000 odysseus \
|
||||
&& useradd --uid 1000 --gid 1000 --home-dir /app --no-create-home --shell /bin/sh odysseus \
|
||||
&& mkdir -p /app/.npm /app/.cache/ms-playwright /app/.cache/ms-playwright-mcp \
|
||||
&& chown -R odysseus:odysseus /app/.npm /app/.cache \
|
||||
&& gosu odysseus env HOME=/app npm_config_cache=/app/.npm \
|
||||
npx -y @playwright/mcp@0.0.78 --version
|
||||
|
||||
# Pinned offline-only runtime for the optional LSP MCP server. The unified
|
||||
# upstream server names its backends as @latest, so its process PATH is later
|
||||
# pointed at the strict local dispatcher below rather than allowing npx to
|
||||
# resolve packages at runtime.
|
||||
COPY docker/lsp-runtime/package.json docker/lsp-runtime/package-lock.json /opt/odysseus-lsp/
|
||||
RUN npm --prefix /opt/odysseus-lsp ci --ignore-scripts --no-audit --no-fund
|
||||
COPY docker/lsp-runtime/npx /opt/odysseus-lsp/bin/npx
|
||||
RUN chmod 0755 /opt/odysseus-lsp/bin/npx
|
||||
COPY docker/lsp-runtime/verify_lsp_runtime.py /opt/odysseus-lsp/verify_lsp_runtime.py
|
||||
|
||||
# Copy app code after the pinned runtime layers.
|
||||
COPY . .
|
||||
|
||||
# Create data directory (mount a volume here for persistence)
|
||||
|
|
|
|||
107
LSP_DOCKER_HANDOFF_20260807_01.md
Normal file
107
LSP_DOCKER_HANDOFF_20260807_01.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# LSP Docker Activation — Handoff
|
||||
|
||||
**Created:** 2026-08-07T16:57:54Z<br>
|
||||
**Scope:** Dockerized Odysseus LSP MCP only.<br>
|
||||
**State:** `PARTIALLY_VERIFIED / LIVE_CONNECTION_VERIFIED / SEMANTIC_CLOSEOUT_PENDING`.
|
||||
|
||||
## Current live state
|
||||
|
||||
| Item | Observed state |
|
||||
|---|---|
|
||||
| Odysseus service | Running Docker container `86b02519adf8a487ffe6be7f1d98946ec9d8df7d2979cd199ff686e05b11fb47` |
|
||||
| Image | `sha256:c2fb085dad45b173c093f8067485b1ff95639ba1fc6ac4c44380c4ea718f0d65` |
|
||||
| Loopback service | `GET http://127.0.0.1:7000/api/health` returned `{"status":"healthy",...}` at 2026-08-07T16:57:55Z |
|
||||
| Dashboard | `127.0.0.1:7001` remains loopback-bound; prior check returned its expected `302` authentication redirect |
|
||||
| LSP record | ID `396e33c2-e4a5-4fae-b60d-b1c1ca5ed0f7`; enabled; Linux container entrypoint `/opt/odysseus-lsp/node_modules/@treedy/lsp-mcp/dist/index.js` |
|
||||
| Live MCP connection | `data/logs/app.log` recorded `MCP server connected: LSP Code Intelligence (Pinned) ... - 101 tools via stdio` at 2026-08-07T16:54:00Z |
|
||||
| Live process | `node /opt/odysseus-lsp/node_modules/@treedy/lsp-mcp/dist/index.js` runs as the `odysseus` user under the current container |
|
||||
| Fixture mount | `tests/fixtures/lsp_docker` is mounted at `/workspace/lsp_fixture` read-only |
|
||||
|
||||
## Implemented changes
|
||||
|
||||
1. **Reproducible image runtime**
|
||||
- Added `docker/lsp-runtime/package.json` and lockfile.
|
||||
- Exact runtime roots: `@treedy/lsp-mcp@0.2.8`, `pyright@1.1.411`, `@treedy/pyright-mcp@1.1.8`, and `@treedy/typescript-lsp-mcp@0.1.7`.
|
||||
- Docker installs the exact lockfile into `/opt/odysseus-lsp` with `npm ci --ignore-scripts --no-audit --no-fund`.
|
||||
|
||||
2. **Runtime network lock**
|
||||
- Added `docker/lsp-runtime/npx`, which intercepts the upstream LSP package's `@latest` backend requests and dispatches only the pinned Python or TypeScript backend.
|
||||
- It delegates `npx --version` only and rejects undeclared package requests with exit `64`.
|
||||
- The live container proved the four installed runtime versions and `SHIM_UNDECLARED_PACKAGE=REJECTED`.
|
||||
|
||||
3. **Safe MCP configuration update**
|
||||
- Added the supported `odysseus-mcp update` command for an existing server's explicit `args`, `env`, and `is_enabled` fields.
|
||||
- Input JSON is validated; unknown IDs fail closed; output redacts all environment values.
|
||||
- The existing LSP record was updated using this CLI while Odysseus was stopped, then Odysseus was recreated.
|
||||
|
||||
4. **Read-only semantic fixture**
|
||||
- Added Python and TypeScript fixtures plus `package.json` and `tsconfig.json`.
|
||||
- Added a single Compose mount: `./tests/fixtures/lsp_docker:/workspace/lsp_fixture:ro,z`.
|
||||
|
||||
5. **Build repair**
|
||||
- The first image rebuild exposed a stale Chrome package pin (`151.0.7922.71-1`) that was no longer present in Google's repository.
|
||||
- Verified repository metadata supplied `151.0.7922.108-1`; Dockerfile and its focused regression test were updated to that exact version.
|
||||
- Second build completed successfully.
|
||||
|
||||
## Evidence achieved
|
||||
|
||||
### Focused source tests
|
||||
|
||||
```text
|
||||
23 passed, 1 warning in 1.02s
|
||||
```
|
||||
|
||||
This included the MCP CLI update tests, Docker hardening tests, MCP dependency pin test, and built-in MCP cache tests.
|
||||
|
||||
### Offline container acceptance
|
||||
|
||||
A temporary container used the newly built image, mounted only the fixture, and ran with `--network none`.
|
||||
|
||||
- Main MCP initialized with `INIT_TOOL_COUNT=101`.
|
||||
- Python backend started from pinned `pyright-mcp@1.1.8`.
|
||||
- TypeScript backend started from pinned `typescript-lsp-mcp@0.1.7`.
|
||||
- Python diagnostics reported the intentional `undefined_symbol` error.
|
||||
- TypeScript diagnostics reported `Type 'string' is not assignable to type 'number'.`
|
||||
- No runtime registry resolution was possible because the temporary container had no network.
|
||||
|
||||
## Preserved rollback point
|
||||
|
||||
| Artifact | SHA-256 | Verification |
|
||||
|---|---|---|
|
||||
| `G:\AIW\05_BAK\ODY_LSP_DOCKER_PRE_ACTIVATION_20260807_165042_02\app.db` | `303e42a612dbf04925289974e9c1ae46fcda6063d382f0a73105e56779200567` | SQLite `integrity_check=ok`; LSP record was disabled and retained its original Windows host path |
|
||||
|
||||
The earlier `_01` backup directory is empty because its first attempted native-Python destination path used an MSYS `/g/...` path and failed before database output was created. It was preserved; nothing was deleted.
|
||||
|
||||
## Remaining closeout work — not executed because the user requested this handoff
|
||||
|
||||
1. Rebuild the image once more. The source-only addition that prints semantic tool schemas in `docker/lsp-runtime/verify_lsp_runtime.py` was made after the currently running image was built. It does not change runtime behavior, but source/image identity must be restored before final closeout.
|
||||
2. Run the finalized offline probe and add the following semantic checks using the now-observed schemas:
|
||||
- `definition(file, line, column)` for `defined_symbol`;
|
||||
- `references(file, line, column, page_size)` for `defined_symbol`;
|
||||
- `rename(file, line, column, newName)` and assert a workspace-edit/dry-run only;
|
||||
- re-hash both fixture source files afterward.
|
||||
3. Perform the same semantic calls through the authenticated live Odysseus MCP manager if a currently authenticated Odysseus UI/browser session is available. Direct HTTP access to `/api/mcp/servers` correctly returns `401`; no credentials or authentication settings were read, changed, or bypassed.
|
||||
4. Run complete post-change focused tests and `docker compose config --quiet`.
|
||||
5. Create the final evidence receipt, DCA learning proposal/entry, and durable Obsidian architecture note.
|
||||
|
||||
## Exact current source hashes
|
||||
|
||||
| Path | SHA-256 |
|
||||
|---|---|
|
||||
| `Dockerfile` | `5d22b5798202111d310b5edabf403969c8c371fa356703db25b6eb1d9bb22bba` |
|
||||
| `docker-compose.yml` | `e8a452d44146d2bda094438165e03886dc1aa40609a5ca02842913bfb53b8c0d` |
|
||||
| `scripts/odysseus-mcp` | `71f213b1acf11f6e8c9d6780c3897faf8ebf7d7f0b1d06417dd36373ad584f33` |
|
||||
| `docker/lsp-runtime/package.json` | `a25c6e7061ac94444fe27f410845619c4898619e9b449602a8311776fc72d1d3` |
|
||||
| `docker/lsp-runtime/package-lock.json` | `4b0a825759e39cb19cdaa421b8cd2e6dd8bc5f28d0dcab7ac8af2d7fc84fbe53` |
|
||||
| `docker/lsp-runtime/npx` | `12bb24b17b000a13cbfc779e1528009c8ac859ccc3e8a5f0e4a849fd89012157` |
|
||||
| `docker/lsp-runtime/verify_lsp_runtime.py` | `4a9269a4629ceb284dec606ebf074bf5c3581bf7f7a77e6355f055f15b374b02` |
|
||||
| `tests/cli/test_mcp_cli_update.py` | `ef0dfbd6104545e1f3d6df1830a49f694b2a2f3414c0afa863b20e566236ad81` |
|
||||
| `tests/fixtures/lsp_docker/python_fixture.py` | `1c7e843c74ca8640effac58523a5161693b76959ce8f8ba1edb396a93a3e37cc` |
|
||||
| `tests/fixtures/lsp_docker/ts_fixture.ts` | `30114e022627e8b16e09148fe6382b5721500bb07cb68e4946eb0ad56bf074b9` |
|
||||
| `data/app.db` at handoff observation | `70cab8c8dbec0ba2c888f23424cd783a6d2ea085d3c25961236c3da1e966bcf6` |
|
||||
|
||||
## Resume authority
|
||||
|
||||
```text
|
||||
GO — resume only the LSP Docker closeout described in LSP_DOCKER_HANDOFF_20260807_01.md. Rebuild only odysseus to bind the current probe source, run its offline semantic definition/references/rename-dry-run checks with no network, re-hash fixtures, then use an already-authenticated Odysseus session if available to exercise the same tools through the live manager. Do not bypass auth, alter providers/ports/credentials, touch Dashboard/Bridge, or recreate any Compose service other than odysseus. Complete only the final receipts, DCA learning path, and Obsidian architecture note after real verification.
|
||||
```
|
||||
76
LSP_DOCKER_HANDOFF_20260807_02.md
Normal file
76
LSP_DOCKER_HANDOFF_20260807_02.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# LSP Docker Activation — Current Handoff
|
||||
|
||||
**Created:** 2026-08-07<br>
|
||||
**Chat scope:** LSP Docker activation for Odysseus only.<br>
|
||||
**Status:** `LIVE_CONNECTION_VERIFIED / SEMANTIC_CLOSEOUT_PENDING`.
|
||||
|
||||
## What is live now
|
||||
|
||||
- Odysseus runs as Docker only on `127.0.0.1:7000`.
|
||||
- Current container: `86b02519adf8a487ffe6be7f1d98946ec9d8df7d2979cd199ff686e05b11fb47`.
|
||||
- Current image: `sha256:c2fb085dad45b173c093f8067485b1ff95639ba1fc6ac4c44380c4ea718f0d65`.
|
||||
- `GET /api/health` returned `{"status":"healthy",...}`.
|
||||
- Dashboard remains on `127.0.0.1:7001`; its prior response was the expected `302` authentication redirect.
|
||||
- The existing LSP MCP record `396e33c2-e4a5-4fae-b60d-b1c1ca5ed0f7` is enabled and now uses:
|
||||
- command: `node`
|
||||
- argument: `/opt/odysseus-lsp/node_modules/@treedy/lsp-mcp/dist/index.js`
|
||||
- Linux-only pinned/offline environment values, redacted by the supported CLI.
|
||||
- Live application log evidence confirms: `LSP Code Intelligence (Pinned) ... 101 tools via stdio`.
|
||||
- The live LSP Node process is present under the Odysseus container.
|
||||
|
||||
## Completed implementation
|
||||
|
||||
1. Added a Docker-internal, lockfile-installed LSP runtime under `/opt/odysseus-lsp`.
|
||||
2. Pinned exact primary packages:
|
||||
- `@treedy/lsp-mcp@0.2.8`
|
||||
- `pyright@1.1.411`
|
||||
- `@treedy/pyright-mcp@1.1.8`
|
||||
- `@treedy/typescript-lsp-mcp@0.1.7`
|
||||
3. Added an offline-only `npx` dispatcher that maps the upstream `@latest` backend requests to the approved pinned local packages and rejects undeclared requests.
|
||||
4. Added a read-only Python/TypeScript fixture mount at `/workspace/lsp_fixture`.
|
||||
5. Added and tested `odysseus-mcp update` to safely update explicit `args`, `env`, and `is_enabled` fields of the existing record; no raw SQLite edit was used.
|
||||
6. Updated the stale Chrome build pin to the currently available exact package version `151.0.7922.108-1`; the Docker hardening test was updated with it.
|
||||
|
||||
## Verified evidence
|
||||
|
||||
- Focused source suite: `23 passed, 1 warning` before the final probe-only edits.
|
||||
- Docker Compose configuration validated.
|
||||
- Rebuilt Odysseus image successfully.
|
||||
- Isolated container acceptance with `--network none` passed for:
|
||||
- MCP initialization: `101` tools.
|
||||
- Python Pyright backend startup: `1.1.8`.
|
||||
- TypeScript backend startup: `0.1.7`.
|
||||
- Python intentional undefined-symbol diagnostic.
|
||||
- TypeScript intentional string-to-number diagnostic.
|
||||
- Unknown package dispatcher rejection.
|
||||
- Fixture source hashes remained unchanged after those offline diagnostics.
|
||||
- Live in-container checks confirmed all four exact package versions and read-only fixture access.
|
||||
|
||||
## Rollback
|
||||
|
||||
| Path | SHA-256 | State |
|
||||
|---|---|---|
|
||||
| `G:\AIW\05_BAK\ODY_LSP_DOCKER_PRE_ACTIVATION_20260807_165042_02\app.db` | `303e42a612dbf04925289974e9c1ae46fcda6063d382f0a73105e56779200567` | SQLite `integrity_check=ok`; original LSP record disabled with its Windows host path |
|
||||
|
||||
The earlier `_01` backup directory is empty: its destination-path attempt failed before a database file was created. It was preserved; nothing was deleted.
|
||||
|
||||
## Exact outstanding work
|
||||
|
||||
Do **not** claim full semantic completion yet.
|
||||
|
||||
1. The current source probe was extended to test `definition`, `references`, and `rename` after the live image build.
|
||||
2. The first new definition check used a coordinate that returned `No definition found at this position`; this is a test-fixture coordinate issue, not a runtime failure.
|
||||
3. The next bounded action is to correct the probe to find the symbol coordinate deterministically, then run:
|
||||
- Python definition;
|
||||
- Python references;
|
||||
- Python rename as workspace-edit/dry-run only;
|
||||
- post-test fixture hashes.
|
||||
4. Rebuild/recreate only `odysseus` once the finalized probe passes so its image exactly matches source.
|
||||
5. An authenticated live manager semantic call remains unverified because `/api/mcp/servers` correctly returns `401`; no credentials or authentication settings were read, changed, or bypassed.
|
||||
6. Then run final focused tests, create a final LSP receipt, route the learning entry through the approved DCA path, and add the stable Obsidian architecture note.
|
||||
|
||||
## Resume authority
|
||||
|
||||
```text
|
||||
GO — resume only LSP Docker semantic closeout. Preserve the live loopback Odysseus service, dashboard, provider settings, credentials, ports, and unrelated containers. Fix the bounded probe coordinate, prove definition/references/rename-dry-run with network disabled, re-hash the read-only fixture, rebuild/recreate only odysseus to bind the final probe source, and finish receipts/DCA learning/vault closeout. Do not bypass Odysseus authentication or touch Dashboard/Bridge.
|
||||
```
|
||||
57
LSP_DOCKER_SEMANTIC_CLOSEOUT_20260808_01.md
Normal file
57
LSP_DOCKER_SEMANTIC_CLOSEOUT_20260808_01.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# LSP Docker Semantic Closeout
|
||||
|
||||
**Observed:** 2026-08-08T05:28:32+12:00<br>
|
||||
**Scope:** Odysseus Docker LSP semantic closeout only.<br>
|
||||
**State:** `LIVE_CONNECTION_VERIFIED / OFFLINE_SEMANTIC_CLOSEOUT_VERIFIED`.
|
||||
|
||||
## Result
|
||||
|
||||
The pinned Docker LSP runtime is rebuilt, recreated, enabled, and healthy on the loopback Odysseus service. The finalized offline acceptance probe passed with Docker networking disabled and proves Python and TypeScript diagnostics plus definition, references, and rename-preview behavior.
|
||||
|
||||
## Root cause and repair
|
||||
|
||||
The original semantic probe used a coordinate search that could select `use_defined_symbol` instead of the intended `defined_symbol` call. A deterministic one-based Python call position is now pinned at `python_fixture.py:6:12`.
|
||||
|
||||
The corrected coordinate exposed a second root cause: the pinned `@treedy/pyright-mcp@1.1.8` rename-preview implementation prefers `rg --line-number --column`; without `ripgrep`, its grep fallback emits a three-field result that the rename parser does not convert into edits. The image now includes `ripgrep`, preserving the pinned npm packages and allowing the provider's primary, column-aware rename-preview path to return two workspace edits.
|
||||
|
||||
## Final evidence
|
||||
|
||||
- `docker run --rm --network none ... /opt/odysseus-lsp/verify_lsp_runtime.py`: exit `0`.
|
||||
- MCP initialization: `101` tools.
|
||||
- Python diagnostic: intentional `undefined_symbol` error detected.
|
||||
- Python definition: fixture call `6:12` resolves to definition `1:5`.
|
||||
- Python references: exactly `2` references.
|
||||
- Python rename preview: `defined_symbol` → `renamed_symbol`, `2` occurrences, preview only.
|
||||
- TypeScript diagnostic: intentional string-to-number type error detected.
|
||||
- TypeScript definition and rename preview: `2` planned locations, preview only.
|
||||
- Focused canonical tests: `20 passed, 3 warnings` (`0.78s`). Warnings are existing SQLAlchemy/Starlette deprecations and the current host pytest `asyncio_mode` configuration warning.
|
||||
- `docker compose config --quiet`: pass.
|
||||
- Live health: `GET http://127.0.0.1:7000/api/health` returned `{"status":"healthy",...}`.
|
||||
- Live process: `node /opt/odysseus-lsp/node_modules/@treedy/lsp-mcp/dist/index.js` present.
|
||||
- Read-only configuration query confirms MCP record `396e33c2-e4a5-4fae-b60d-b1c1ca5ed0f7` is named `LSP Code Intelligence (Pinned)`, uses `node` with the pinned LSP entrypoint, and has `is_enabled=1`.
|
||||
- Exact in-container package versions: `@treedy/lsp-mcp=0.2.8`, `@treedy/pyright-mcp=1.1.8`, `@treedy/typescript-lsp-mcp=0.1.7`, `pyright=1.1.411`.
|
||||
- Fixture mount remains read-only; source fixture hashes are unchanged.
|
||||
|
||||
## Final live identity
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Container | `2b22ea8da7b8964f9851a1087278a4e9490d073f4ac026cde8c7e625ea6e6ad9` |
|
||||
| Image | `sha256:1deb93bf43b88de04237578b66343e986848b88f7219ef8320672ee7915cee8a` |
|
||||
| Service | `ody-odysseus-1` on `127.0.0.1:7000` |
|
||||
| LSP probe source | `docker/lsp-runtime/verify_lsp_runtime.py` SHA-256 `11e776d33322b4f13ac0c458649139bbc2b3ed26bee2ec63077713b1fb06f77e` |
|
||||
| Dockerfile | SHA-256 `2257d7b51f6306e67299fc3c275df3a067bbadb90542530dca81afe148627a67` |
|
||||
| Python fixture | SHA-256 `1c7e843c74ca8640effac58523a5161693b76959ce8f8ba1edb396a93a3e37cc` |
|
||||
| TypeScript fixture | SHA-256 `30114e022627e8b16e09148fe6382b5721500bb07cb68e4946eb0ad56bf074b9` |
|
||||
|
||||
## Boundaries retained
|
||||
|
||||
- No dashboard, bridge, provider settings, credentials, or authentication settings were read, changed, or bypassed.
|
||||
- No semantic rename was applied: both rename checks returned previews only, and the fixture mount was verified read-only.
|
||||
- No network was available to the final semantic acceptance container.
|
||||
- The authenticated manager API semantic call remains intentionally untested because its authentication boundary remains in force.
|
||||
- A transient SQLite-lock message was observed during the first post-recreate warm-up. The service was subsequently recreated once after the root-cause repair and returned healthy; no unrelated database/session behavior was changed under this LSP-only authority.
|
||||
|
||||
## Durable-memory closeout
|
||||
|
||||
`verify/VERIFY_LOG.md` cannot be updated through DCA because the active allowlist permits only `memory/` and `registry/`. `memory/LEARNING.md` already has two pending full-file proposals in a linear chain. A further learning entry must be generated as their exact successor, preserving their decoded full content and successor hash; it was not staged in this closeout rather than risk a stale full-file overwrite. A separate, non-conflicting architecture-note proposal is staged through the DCA inbox; it is queued only and is not represented as applied until a DCA receipt exists.
|
||||
|
|
@ -16,6 +16,9 @@ services:
|
|||
# land under /app/.local for the odysseus user. Persist them so a
|
||||
# container recreate does not silently remove installed serve engines.
|
||||
- ${APP_DATA_DIR:-./data}/local:/app/.local:z
|
||||
# LSP semantic acceptance fixture. It is the only code workspace exposed
|
||||
# to the optional LSP MCP process and is intentionally read-only.
|
||||
- ./tests/fixtures/lsp_docker:/workspace/lsp_fixture:ro,z
|
||||
extra_hosts:
|
||||
# Lets the container reach local services on the Docker host, including
|
||||
# Ollama at http://host.docker.internal:11434.
|
||||
|
|
|
|||
|
|
@ -96,7 +96,11 @@ repair_bind_mount_ownership() {
|
|||
# Repair image-owned writable paths without walking into bind-mounted host
|
||||
# trees, then repair the app-owned mount roots separately.
|
||||
repair_app_tree_ownership
|
||||
for dir in /app/data /app/logs /app/.ssh /app/.cache/huggingface /app/.local; do
|
||||
# Playwright and its MCP server store browser binaries/profile data under these
|
||||
# cache paths. They must be writable by the non-root app user before Browser
|
||||
# MCP navigation can run.
|
||||
mkdir -p /app/.cache/ms-playwright /app/.cache/ms-playwright-mcp
|
||||
for dir in /app/data /app/logs /app/.ssh /app/.cache/huggingface /app/.cache/ms-playwright /app/.cache/ms-playwright-mcp /app/.local; do
|
||||
repair_bind_mount_ownership "$dir"
|
||||
done
|
||||
|
||||
|
|
|
|||
27
docker/lsp-runtime/npx
Normal file
27
docker/lsp-runtime/npx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#!/bin/sh
|
||||
# Offline-only dispatcher for the backends hard-coded as @latest by lsp-mcp.
|
||||
# It deliberately accepts only the pinned backend requests and npx --version.
|
||||
set -eu
|
||||
|
||||
if [ "${1:-}" = "--version" ]; then
|
||||
exec /usr/bin/npx --version
|
||||
fi
|
||||
|
||||
if [ "${1:-}" = "--yes" ] || [ "${1:-}" = "-y" ]; then
|
||||
shift
|
||||
fi
|
||||
|
||||
case "${1:-}" in
|
||||
"@treedy/pyright-mcp@latest"|"@treedy/pyright-mcp@1.1.8")
|
||||
shift
|
||||
exec node /opt/odysseus-lsp/node_modules/@treedy/pyright-mcp/dist/index.js "$@"
|
||||
;;
|
||||
"@treedy/typescript-lsp-mcp@latest"|"@treedy/typescript-lsp-mcp@0.1.7")
|
||||
shift
|
||||
exec node /opt/odysseus-lsp/node_modules/@treedy/typescript-lsp-mcp/dist/index.js "$@"
|
||||
;;
|
||||
*)
|
||||
printf '%s\n' "odysseus-lsp-npx: refused undeclared runtime package: ${1:-<none>}" >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
1318
docker/lsp-runtime/package-lock.json
generated
Normal file
1318
docker/lsp-runtime/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
13
docker/lsp-runtime/package.json
Normal file
13
docker/lsp-runtime/package.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"name": "odysseus-lsp-runtime",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Pinned offline runtime for Odysseus LSP MCP",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@treedy/lsp-mcp": "0.2.8",
|
||||
"@treedy/pyright-mcp": "1.1.8",
|
||||
"@treedy/typescript-lsp-mcp": "0.1.7",
|
||||
"pyright": "1.1.411"
|
||||
}
|
||||
}
|
||||
176
docker/lsp-runtime/verify_lsp_runtime.py
Normal file
176
docker/lsp-runtime/verify_lsp_runtime.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""Offline container acceptance probe for Odysseus's pinned LSP MCP runtime."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
RUNTIME_ENV = {
|
||||
"LSP_MCP_AUTO_UPDATE": "false",
|
||||
"LSP_MCP_PYTHON_PROVIDER": "pyright-mcp",
|
||||
"LSP_MCP_TYPESCRIPT_ENABLED": "true",
|
||||
"LSP_MCP_VUE_ENABLED": "false",
|
||||
"LSP_MCP_BACKEND_RUNTIME_MODE": "registry",
|
||||
"npm_config_offline": "true",
|
||||
"PATH": "/opt/odysseus-lsp/bin:/opt/odysseus-lsp/node_modules/.bin:/usr/local/bin:/usr/bin:/bin",
|
||||
}
|
||||
REQUIRED_TOOLS = {
|
||||
"status",
|
||||
"list_backends",
|
||||
"start_backend",
|
||||
"switch_workspace_for_language",
|
||||
"diagnostics",
|
||||
"definition",
|
||||
"references",
|
||||
"rename",
|
||||
}
|
||||
PYTHON_DEFINED_SYMBOL_CALL = {
|
||||
"file": "/workspace/lsp_fixture/python_fixture.py",
|
||||
"line": 6,
|
||||
"column": 12,
|
||||
}
|
||||
TYPESCRIPT_DEFINED_SYMBOL_CALL = {
|
||||
"file": "/workspace/lsp_fixture/ts_fixture.ts",
|
||||
"line": 5,
|
||||
"column": 35,
|
||||
}
|
||||
|
||||
|
||||
def _text(result) -> str:
|
||||
return "\n".join(getattr(item, "text", "") for item in result.content)
|
||||
|
||||
|
||||
async def _call(session, name: str, arguments: dict) -> str:
|
||||
result = await session.call_tool(name, arguments)
|
||||
payload = _text(result)
|
||||
if getattr(result, "isError", False) or '"success": false' in payload:
|
||||
raise RuntimeError(f"{name} failed: {payload}")
|
||||
return payload
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
params = StdioServerParameters(
|
||||
command="node",
|
||||
args=["/opt/odysseus-lsp/node_modules/@treedy/lsp-mcp/dist/index.js"],
|
||||
env=RUNTIME_ENV,
|
||||
)
|
||||
async with stdio_client(params) as streams:
|
||||
async with ClientSession(*streams) as session:
|
||||
await session.initialize()
|
||||
tools = await session.list_tools()
|
||||
names = {tool.name for tool in tools.tools}
|
||||
missing = sorted(REQUIRED_TOOLS - names)
|
||||
if missing:
|
||||
raise RuntimeError(f"required tools missing: {', '.join(missing)}")
|
||||
print(f"INIT_TOOL_COUNT={len(names)}")
|
||||
schema_by_name = {
|
||||
tool.name: tool.inputSchema
|
||||
for tool in tools.tools
|
||||
if tool.name in {"definition", "references", "rename"}
|
||||
}
|
||||
print(f"SEMANTIC_TOOL_SCHEMAS={json.dumps(schema_by_name, sort_keys=True)}")
|
||||
|
||||
for language in ("python", "typescript"):
|
||||
await _call(
|
||||
session,
|
||||
"switch_workspace_for_language",
|
||||
{"language": language, "path": "/workspace/lsp_fixture"},
|
||||
)
|
||||
payload = await _call(session, "start_backend", {"language": language})
|
||||
print(f"START_{language.upper()}={payload}")
|
||||
|
||||
python_diagnostics = await _call(
|
||||
session,
|
||||
"diagnostics",
|
||||
{
|
||||
"path": "/workspace/lsp_fixture/python_fixture.py",
|
||||
"summary_only": True,
|
||||
"page_size": 10,
|
||||
},
|
||||
)
|
||||
if "undefined_symbol" not in python_diagnostics:
|
||||
raise RuntimeError(f"expected Python diagnostic not found: {python_diagnostics}")
|
||||
print(f"PYTHON_DIAGNOSTICS={python_diagnostics}")
|
||||
|
||||
definition = await _call(
|
||||
session, "definition", PYTHON_DEFINED_SYMBOL_CALL
|
||||
)
|
||||
if (
|
||||
"Definition(s)" not in definition
|
||||
or "/workspace/lsp_fixture/python_fixture.py:1:5" not in definition
|
||||
):
|
||||
raise RuntimeError(f"expected Python definition not found: {definition}")
|
||||
print(f"PYTHON_DEFINITION={definition}")
|
||||
|
||||
references = await _call(
|
||||
session,
|
||||
"references",
|
||||
{
|
||||
**PYTHON_DEFINED_SYMBOL_CALL,
|
||||
"page_size": 20,
|
||||
},
|
||||
)
|
||||
if "defined_symbol" not in references or "Found 2 reference(s)" not in references:
|
||||
raise RuntimeError(f"expected Python references not found: {references}")
|
||||
print(f"PYTHON_REFERENCES={references}")
|
||||
|
||||
python_rename = await _call(
|
||||
session,
|
||||
"rename",
|
||||
{
|
||||
**PYTHON_DEFINED_SYMBOL_CALL,
|
||||
"newName": "renamed_symbol",
|
||||
},
|
||||
)
|
||||
if (
|
||||
"Rename Preview" not in python_rename
|
||||
or "renamed_symbol" not in python_rename
|
||||
or "Found 2 occurrence(s)" not in python_rename
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"expected Python rename workspace edit not found: {python_rename}"
|
||||
)
|
||||
print(f"PYTHON_RENAME_DRY_RUN={python_rename}")
|
||||
|
||||
ts_diagnostics = await _call(
|
||||
session,
|
||||
"diagnostics",
|
||||
{
|
||||
"path": "/workspace/lsp_fixture/ts_fixture.ts",
|
||||
"summary_only": False,
|
||||
"page_size": 10,
|
||||
},
|
||||
)
|
||||
if "not assignable" not in ts_diagnostics and "Type" not in ts_diagnostics:
|
||||
raise RuntimeError(f"expected TypeScript diagnostic not found: {ts_diagnostics}")
|
||||
print(f"TS_DIAGNOSTICS={ts_diagnostics}")
|
||||
|
||||
ts_definition = await _call(
|
||||
session, "definition", TYPESCRIPT_DEFINED_SYMBOL_CALL
|
||||
)
|
||||
if '"name":"definedTsSymbol"' not in ts_definition:
|
||||
raise RuntimeError(
|
||||
f"expected TypeScript definition not found: {ts_definition}"
|
||||
)
|
||||
print(f"TS_DEFINITION={ts_definition}")
|
||||
|
||||
rename = await _call(
|
||||
session,
|
||||
"rename",
|
||||
{
|
||||
**TYPESCRIPT_DEFINED_SYMBOL_CALL,
|
||||
"newName": "renamedTsSymbol",
|
||||
},
|
||||
)
|
||||
if (
|
||||
'"preview":true' not in rename
|
||||
or "renamedTsSymbol" not in rename
|
||||
or '"totalLocations":2' not in rename
|
||||
):
|
||||
raise RuntimeError(f"expected rename workspace edit not found: {rename}")
|
||||
print(f"TS_RENAME_DRY_RUN={rename}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
|
|
@ -38,7 +38,9 @@ python-dateutil
|
|||
caldav
|
||||
cryptography
|
||||
bcrypt
|
||||
mcp
|
||||
# Built-in MCP servers use Server.list_tools()/Server.call_tool() decorators,
|
||||
# which were removed in mcp 2. Pin the verified compatible API contract.
|
||||
mcp==1.28.1
|
||||
pyotp
|
||||
qrcode[pil]
|
||||
croniter
|
||||
|
|
|
|||
|
|
@ -1338,18 +1338,42 @@ def setup_session_routes(
|
|||
|
||||
@router.get("/session/{session_id}/context_info")
|
||||
async def get_context_info(request: Request, session_id: str):
|
||||
"""Get the real context length for a session's model from the endpoint."""
|
||||
"""Return the session's proven runtime context and effective budget."""
|
||||
_verify_session_owner(request, session_id)
|
||||
session = session_manager.get_session(session_id)
|
||||
if not session:
|
||||
raise HTTPException(404, "Session not found")
|
||||
if not session.endpoint_url or not session.model:
|
||||
return {"context_length": None}
|
||||
return {
|
||||
"context_length": None,
|
||||
"model": getattr(session, "model", None),
|
||||
"budget_context": 0,
|
||||
"context_source": None,
|
||||
}
|
||||
try:
|
||||
from src.model_context import get_context_length
|
||||
ctx = get_context_length(session.endpoint_url, session.model)
|
||||
return {"context_length": ctx, "model": session.model}
|
||||
from src.model_context import get_context_length_known, _local_ollama_ps_url
|
||||
|
||||
ctx, known = get_context_length_known(session.endpoint_url, session.model)
|
||||
if not known or not isinstance(ctx, int) or ctx <= 0:
|
||||
return {
|
||||
"context_length": None,
|
||||
"model": session.model,
|
||||
"budget_context": 0,
|
||||
"context_source": None,
|
||||
}
|
||||
source = "ollama_api_ps" if _local_ollama_ps_url(session.endpoint_url) else "endpoint_metadata"
|
||||
return {
|
||||
"context_length": ctx,
|
||||
"model": session.model,
|
||||
"budget_context": ctx,
|
||||
"context_source": source,
|
||||
}
|
||||
except Exception:
|
||||
return {"context_length": None}
|
||||
return {
|
||||
"context_length": None,
|
||||
"model": session.model,
|
||||
"budget_context": 0,
|
||||
"context_source": None,
|
||||
}
|
||||
|
||||
return router
|
||||
|
|
|
|||
|
|
@ -111,6 +111,46 @@ def _set_enabled(server_id: str, enabled: bool, args):
|
|||
db.close()
|
||||
|
||||
|
||||
def _parse_update_json(raw, field_name, expected_type):
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"invalid {field_name}: {exc}") from exc
|
||||
if not isinstance(value, expected_type):
|
||||
expected_name = "array" if expected_type is list else "object"
|
||||
raise ValueError(f"invalid {field_name}: expected JSON {expected_name}")
|
||||
return value
|
||||
|
||||
|
||||
def cmd_update(args):
|
||||
"""Update only the explicit mutable MCP fields for one existing server."""
|
||||
updates = {}
|
||||
try:
|
||||
if args.args is not None:
|
||||
updates["args"] = json.dumps(_parse_update_json(args.args, "args", list))
|
||||
if args.env is not None:
|
||||
updates["env"] = json.dumps(_parse_update_json(args.env, "env", dict))
|
||||
except ValueError as exc:
|
||||
fail(str(exc))
|
||||
|
||||
if args.is_enabled is not None:
|
||||
updates["is_enabled"] = args.is_enabled == "true"
|
||||
if not updates:
|
||||
fail("update requires --args, --env, or --is-enabled")
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
server = db.get(McpServer, args.id)
|
||||
if not server:
|
||||
fail(f"no MCP server with id {args.id!r}")
|
||||
for field_name, value in updates.items():
|
||||
setattr(server, field_name, value)
|
||||
db.commit()
|
||||
emit(_serialize(server), args)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def cmd_add(args):
|
||||
if args.transport == "stdio" and not args.command:
|
||||
fail("--command is required for stdio transport")
|
||||
|
|
@ -185,6 +225,13 @@ def _build_parser():
|
|||
pdis.add_argument("id")
|
||||
pdis.set_defaults(func=cmd_disable)
|
||||
|
||||
pu = sub.add_parser("update", parents=[common])
|
||||
pu.add_argument("id")
|
||||
pu.add_argument("--args", help="replace stdio args with a JSON array")
|
||||
pu.add_argument("--env", help="replace environment with a JSON object")
|
||||
pu.add_argument("--is-enabled", choices=("true", "false"))
|
||||
pu.set_defaults(func=cmd_update)
|
||||
|
||||
pa = sub.add_parser("add", parents=[common])
|
||||
pa.add_argument("--name", required=True)
|
||||
pa.add_argument("--transport", choices=["stdio", "sse"], default="stdio")
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import json
|
|||
import re
|
||||
import time
|
||||
import logging
|
||||
from typing import AsyncGenerator, List, Dict, Optional, Set
|
||||
from typing import Any, AsyncGenerator, List, Dict, Optional, Set
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from src.llm_core import (
|
||||
|
|
@ -897,6 +897,26 @@ _ADMIN_SCHEMA_NAMES = frozenset([
|
|||
_TOOL_SELECTION_TIMEOUT_SECONDS = 1.5
|
||||
|
||||
|
||||
def _is_mcp_server_inventory_request(text: str) -> bool:
|
||||
"""Return True for read-only requests to list Odysseus MCP servers.
|
||||
|
||||
These requests belong to the native ``manage_mcp`` inventory tool. They
|
||||
must not expose a local text-model to an unrelated browser MCP schema: that
|
||||
failure mode made the model navigate to a fabricated example.com host
|
||||
instead of asking the MCP manager for its actual registered servers.
|
||||
"""
|
||||
normalized = str(text or "").lower()
|
||||
if not re.search(r"\bmcps?\b", normalized):
|
||||
return False
|
||||
if re.search(r"\b(?:tool|tools)\b", normalized):
|
||||
return False
|
||||
return bool(re.search(
|
||||
r"\b(?:server|servers)\b.*\b(?:installed|available|connected|configured|list|show|what|which)\b"
|
||||
r"|\b(?:what|which|list|show)\b.*\b(?:mcp|mcps)\b.*\b(?:server|servers)\b",
|
||||
normalized,
|
||||
))
|
||||
|
||||
|
||||
def _is_ollama_openai_compat_url(endpoint_url: str) -> bool:
|
||||
"""Return True for local Ollama's OpenAI-compatible /v1 surface.
|
||||
|
||||
|
|
@ -3474,6 +3494,13 @@ async def stream_agent_loop(
|
|||
|
||||
if not guide_only and _relevant_tools is not None:
|
||||
_relevant_tools = _expand_browser_mcp_tools(_relevant_tools, mcp_mgr)
|
||||
# A direct MCP-server inventory question has one authoritative source:
|
||||
# manage_mcp(action="list"). Do not let semantic retrieval leak an
|
||||
# unrelated browser-MCP tool into this local text-model turn, because local
|
||||
# models receive only the MCP schemas and may then fabricate a browser URL.
|
||||
if not guide_only and _is_mcp_server_inventory_request(_last_user):
|
||||
_relevant_tools = {"manage_mcp", "ask_user", "update_plan"}
|
||||
logger.info("[agent-intent] MCP server inventory tool clamp=%s", sorted(_relevant_tools))
|
||||
|
||||
# The skill index injected by _build_system_prompt tells the model to
|
||||
# call `manage_skills action=view`, and Jaccard-matched skills are pasted
|
||||
|
|
@ -3938,10 +3965,18 @@ async def stream_agent_loop(
|
|||
and t.get("name") not in disabled_tools
|
||||
]
|
||||
else:
|
||||
# Local: only MCP schemas when message suggests MCP tool usage
|
||||
# Local text-only models use the fenced-tool prompt for native
|
||||
# tools. MCP schemas are optional and must be restricted to MCP
|
||||
# tools selected for THIS request; sending every browser schema on
|
||||
# a simple MCP inventory question crowds out manage_mcp and causes
|
||||
# fabricated navigation attempts.
|
||||
_last_content = _last_user.lower()
|
||||
_wants_mcp = any(kw in _last_content for kw in _MCP_KEYWORDS)
|
||||
all_tool_schemas = mcp_schemas if (_wants_mcp and mcp_schemas) else []
|
||||
_selected_mcp_tools = set(_relevant_tools or set())
|
||||
all_tool_schemas = [
|
||||
schema for schema in mcp_schemas
|
||||
if schema.get("function", {}).get("name") in _selected_mcp_tools
|
||||
] if (_wants_mcp and mcp_schemas) else []
|
||||
agent_stream_timeout = int(get_setting("agent_stream_timeout_seconds", 300) or 300)
|
||||
|
||||
_tool_names_sent = [t.get("function", {}).get("name") for t in (all_tool_schemas or []) if t.get("function")]
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ _BUILTIN_NPX_SERVERS = {
|
|||
"builtin_browser": {
|
||||
"name": "Built-in: Browser",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@playwright/mcp@latest", "--headless", "--caps", "vision"],
|
||||
"args": ["-y", "@playwright/mcp@0.0.78", "--headless", "--caps", "vision"],
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
1
src/cook_ops/__init__.py
Normal file
1
src/cook_ops/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Odysseus Cook: bounded, contract-driven basic-task operations."""
|
||||
1
src/cook_ops/agents/__init__.py
Normal file
1
src/cook_ops/agents/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""The ten bounded Cook worker entry points."""
|
||||
17
src/cook_ops/agents/_common.py
Normal file
17
src/cook_ops/agents/_common.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def receipt(contract, agent: str, action: str, source: Path | None, destination: Path | None, digest: str | None, exit_code: int, status: str) -> dict:
|
||||
from datetime import datetime, timezone
|
||||
return {"task_id": contract.task_id, "agent": agent, "action": action, "source": str(source) if source else None, "destination": str(destination) if destination else None, "sha256": digest, "exit_code": exit_code, "utc": datetime.now(timezone.utc).isoformat(), "status": status}
|
||||
1
src/cook_ops/agents/file_classifier.py
Normal file
1
src/cook_ops/agents/file_classifier.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
def run(contract, inventory, rules): return {"status": "BLOCKED" if not rules else "VERIFIED", "rules": tuple(rules)}
|
||||
11
src/cook_ops/agents/file_inventory.py
Normal file
11
src/cook_ops/agents/file_inventory.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from pathlib import Path
|
||||
from ._common import sha256
|
||||
|
||||
def run(contract):
|
||||
rows = []
|
||||
for source in contract.source_paths:
|
||||
if not source.is_file():
|
||||
return {"status": "BLOCKED", "reason": f"source inaccessible: {source}", "items": rows}
|
||||
destination = contract.destination_path / source.name
|
||||
rows.append({"source": source, "destination": destination, "size": source.stat().st_size, "sha256": sha256(source), "exists_at_destination": destination.exists(), "destination_sha256": sha256(destination) if destination.is_file() else None})
|
||||
return {"status": "VERIFIED", "items": rows}
|
||||
9
src/cook_ops/agents/file_move_planner.py
Normal file
9
src/cook_ops/agents/file_move_planner.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
def run(contract, inventory):
|
||||
if inventory["status"] != "VERIFIED":
|
||||
return {"status": "BLOCKED", "reason": "inventory is not verified", "items": []}
|
||||
items = []
|
||||
for row in inventory["items"]:
|
||||
if row["exists_at_destination"] and row["destination_sha256"] != row["sha256"]:
|
||||
return {"status": "BLOCKED", "reason": f"different content collision: {row['destination']}", "items": []}
|
||||
items.append({**row, "duplicate": row["exists_at_destination"]})
|
||||
return {"status": "READY_FOR_APPROVAL", "items": items}
|
||||
24
src/cook_ops/agents/file_mover.py
Normal file
24
src/cook_ops/agents/file_mover.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import shutil
|
||||
from ._common import receipt, sha256
|
||||
|
||||
def run(contract, plan):
|
||||
if not contract.approved or plan.get("status") != "READY_FOR_APPROVAL":
|
||||
return [receipt(contract, "FILE_MOVER", "MOVE", None, None, None, 1, "BLOCKED")]
|
||||
rows = []
|
||||
for item in plan["items"]:
|
||||
source, destination = item["source"], item["destination"]
|
||||
if sha256(source) != item["sha256"]:
|
||||
return rows + [receipt(contract, "FILE_MOVER", contract.operation.value.upper(), source, destination, None, 1, "RECOVERY_REQUIRED")]
|
||||
if item["duplicate"]:
|
||||
rows.append(receipt(contract, "FILE_MOVER", "DUPLICATE", source, destination, item["sha256"], 0, "VERIFIED")); continue
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if contract.operation.value == "copy":
|
||||
shutil.copy2(source, destination)
|
||||
elif contract.operation.value == "move":
|
||||
shutil.move(str(source), str(destination))
|
||||
else:
|
||||
return rows + [receipt(contract, "FILE_MOVER", "MOVE", source, destination, None, 1, "BLOCKED")]
|
||||
status = "VERIFIED" if destination.is_file() and sha256(destination) == item["sha256"] else "RECOVERY_REQUIRED"
|
||||
rows.append(receipt(contract, "FILE_MOVER", contract.operation.value.upper(), source, destination, item["sha256"], 0 if status == "VERIFIED" else 1, status))
|
||||
if status != "VERIFIED": return rows
|
||||
return rows
|
||||
10
src/cook_ops/agents/file_verify.py
Normal file
10
src/cook_ops/agents/file_verify.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from ._common import sha256
|
||||
|
||||
def run(contract, plan):
|
||||
if plan.get("status") != "READY_FOR_APPROVAL": return {"status": "BLOCKED", "reason": "unapproved plan"}
|
||||
for item in plan["items"]:
|
||||
source, destination = item["source"], item["destination"]
|
||||
if not destination.is_file() or sha256(destination) != item["sha256"]: return {"status": "RECOVERY_REQUIRED", "reason": f"destination hash mismatch: {destination}"}
|
||||
if contract.operation.value == "copy" and (not source.is_file() or sha256(source) != item["sha256"]): return {"status": "RECOVERY_REQUIRED", "reason": f"copy source state mismatch: {source}"}
|
||||
if contract.operation.value == "move" and source.exists(): return {"status": "RECOVERY_REQUIRED", "reason": f"moved source remains: {source}"}
|
||||
return {"status": "VERIFIED"}
|
||||
6
src/cook_ops/agents/human_speech_and_handoff.py
Normal file
6
src/cook_ops/agents/human_speech_and_handoff.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
def run(contract, result):
|
||||
status = result.get("status", "BLOCKED")
|
||||
if status == "VERIFIED": text = "The approved outcome was independently verified."
|
||||
elif status == "RECOVERY_REQUIRED": text = "I stopped safely and preserved evidence; Jeremy must choose the next action."
|
||||
else: text = "No execution claim is made; the task is blocked or awaiting approval."
|
||||
return {"status": status, "human": text}
|
||||
14
src/cook_ops/agents/powershell_executor.py
Normal file
14
src/cook_ops/agents/powershell_executor.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import hashlib, subprocess, tempfile
|
||||
from pathlib import Path
|
||||
from ._common import receipt
|
||||
|
||||
def run(contract, script: str, approved_script_sha256: str):
|
||||
digest = hashlib.sha256(script.encode()).hexdigest()
|
||||
if not contract.approved:
|
||||
return receipt(contract, "POWERSHELL_EXECUTOR", "POWERSHELL", None, None, digest, 1, "BLOCKED")
|
||||
if digest != approved_script_sha256: return receipt(contract, "POWERSHELL_EXECUTOR", "POWERSHELL", None, None, digest, 1, "BLOCKED")
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".ps1", encoding="utf-8", delete=False) as handle: handle.write(script); path = Path(handle.name)
|
||||
try:
|
||||
result = subprocess.run(["powershell.exe", "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-File", str(path)], capture_output=True, text=True, timeout=60)
|
||||
return {**receipt(contract, "POWERSHELL_EXECUTOR", "POWERSHELL", path, None, digest, result.returncode, "RUNNING" if result.returncode == 0 else "FAILED"), "stdout": result.stdout[:20000], "stderr": result.stderr[:20000]}
|
||||
finally: path.unlink(missing_ok=True)
|
||||
9
src/cook_ops/agents/powershell_preflight.py
Normal file
9
src/cook_ops/agents/powershell_preflight.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import hashlib
|
||||
import re
|
||||
|
||||
_FORBIDDEN = re.compile(r"\b(New-Service|Set-Service|Register-ScheduledTask|schtasks|Set-ItemProperty|New-ItemProperty|Set-Acl|icacls|Invoke-WebRequest|curl|wget)\b", re.I)
|
||||
def run(contract, script: str):
|
||||
if contract.operation.value != "powershell": return {"status": "BLOCKED", "reason": "not a PowerShell contract"}
|
||||
if _FORBIDDEN.search(script): return {"status": "BLOCKED", "reason": "forbidden PowerShell capability"}
|
||||
if script.count("'") % 2 or script.count('"') % 2: return {"status": "BLOCKED", "reason": "unbalanced quote"}
|
||||
return {"status": "READY_FOR_APPROVAL", "script_sha256": hashlib.sha256(script.encode()).hexdigest()}
|
||||
1
src/cook_ops/agents/recovery_and_quarantine.py
Normal file
1
src/cook_ops/agents/recovery_and_quarantine.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
def run(contract, failure): return {"status": "RECOVERY_REQUIRED", "task_id": contract.task_id, "evidence": failure, "next_action": "Escalate to Jeremy; no automatic retry or deletion."}
|
||||
1
src/cook_ops/agents/task_intake.py
Normal file
1
src/cook_ops/agents/task_intake.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
def run(contract): return {"status": "PROPOSED", "task_id": contract.task_id, "contract_sha256": contract.contract_sha256}
|
||||
81
src/cook_ops/contracts.py
Normal file
81
src/cook_ops/contracts.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Immutable task and skill binding contracts for Odysseus Cook."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass, replace
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
|
||||
class Operation(str, Enum):
|
||||
INSPECT = "inspect"
|
||||
COPY = "copy"
|
||||
MOVE = "move"
|
||||
POWERSHELL = "powershell"
|
||||
|
||||
|
||||
class TaskStatus(str, Enum):
|
||||
PROPOSED = "PROPOSED"
|
||||
READY_FOR_APPROVAL = "READY_FOR_APPROVAL"
|
||||
RUNNING = "RUNNING"
|
||||
VERIFIED = "VERIFIED"
|
||||
BLOCKED = "BLOCKED"
|
||||
FAILED = "FAILED"
|
||||
RECOVERY_REQUIRED = "RECOVERY_REQUIRED"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkillBinding:
|
||||
skill_id: str
|
||||
version: str
|
||||
sha256: str
|
||||
allowed_roles: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskContract:
|
||||
task_id: str
|
||||
operation: Operation
|
||||
source_paths: tuple[Path, ...]
|
||||
destination_path: Path | None
|
||||
allowed_roots: tuple[Path, ...]
|
||||
skill_bindings: tuple[SkillBinding, ...]
|
||||
protected_zone_exceptions: tuple[Path, ...] = ()
|
||||
status: TaskStatus = TaskStatus.PROPOSED
|
||||
approved: bool = False
|
||||
contract_sha256: str = ""
|
||||
|
||||
def canonical_payload(self) -> dict:
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"operation": self.operation.value,
|
||||
"source_paths": [str(path) for path in self.source_paths],
|
||||
"destination_path": str(self.destination_path) if self.destination_path else None,
|
||||
"allowed_roots": [str(path) for path in self.allowed_roots],
|
||||
"protected_zone_exceptions": [str(path) for path in self.protected_zone_exceptions],
|
||||
"skill_bindings": [
|
||||
{
|
||||
"skill_id": binding.skill_id,
|
||||
"version": binding.version,
|
||||
"sha256": binding.sha256,
|
||||
"allowed_roles": list(binding.allowed_roles),
|
||||
}
|
||||
for binding in self.skill_bindings
|
||||
],
|
||||
}
|
||||
|
||||
def with_hash(self) -> "TaskContract":
|
||||
encoded = json.dumps(
|
||||
self.canonical_payload(), sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
return replace(self, contract_sha256=hashlib.sha256(encoded).hexdigest())
|
||||
|
||||
def mark_approved(self) -> "TaskContract":
|
||||
return replace(self, approved=True, status=TaskStatus.READY_FOR_APPROVAL)
|
||||
|
||||
|
||||
def resolve_paths(paths: Iterable[Path]) -> tuple[Path, ...]:
|
||||
return tuple(Path(path).expanduser().resolve() for path in paths)
|
||||
80
src/cook_ops/controller.py
Normal file
80
src/cook_ops/controller.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
"""Single-dispatch controller for immutable Odysseus Cook task contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from src.cook_ops.contracts import Operation, SkillBinding, TaskContract, resolve_paths
|
||||
|
||||
|
||||
_PROTECTED_ZONE_ROOTS = resolve_paths((
|
||||
Path(r"G:\AIW\00_IN"),
|
||||
Path(r"G:\AIW\05_BAK"),
|
||||
))
|
||||
|
||||
|
||||
class CookController:
|
||||
"""Build and approve bounded contracts; execution is intentionally absent here."""
|
||||
|
||||
def __init__(self, skill_registry: tuple[SkillBinding, ...]) -> None:
|
||||
self._skills = {binding.skill_id: binding for binding in skill_registry}
|
||||
|
||||
@staticmethod
|
||||
def _validate_inside_allowed_roots(paths: tuple[Path, ...], roots: tuple[Path, ...]) -> None:
|
||||
if not roots:
|
||||
raise ValueError("allowed roots are required")
|
||||
for path in paths:
|
||||
if not any(path == root or root in path.parents for root in roots):
|
||||
raise ValueError(f"path {path} is outside allowed roots")
|
||||
|
||||
@staticmethod
|
||||
def _validate_protected_zone_paths(paths: tuple[Path, ...], exceptions: tuple[Path, ...]) -> None:
|
||||
protected_paths = tuple(
|
||||
path for path in paths
|
||||
if any(path == root or root in path.parents for root in _PROTECTED_ZONE_ROOTS)
|
||||
)
|
||||
protected_set = set(protected_paths)
|
||||
exception_set = set(exceptions)
|
||||
if protected_set != exception_set:
|
||||
raise ValueError("protected zone paths require exact protected zone exceptions")
|
||||
|
||||
def propose(
|
||||
self,
|
||||
*,
|
||||
operation: Operation,
|
||||
source_paths: list[Path],
|
||||
destination_path: Path | None,
|
||||
allowed_roots: list[Path],
|
||||
skill_ids: list[str] | None = None,
|
||||
protected_zone_exceptions: list[Path] | None = None,
|
||||
) -> TaskContract:
|
||||
sources = resolve_paths(source_paths)
|
||||
roots = resolve_paths(allowed_roots)
|
||||
destination = Path(destination_path).expanduser().resolve() if destination_path else None
|
||||
exceptions = resolve_paths(protected_zone_exceptions or [])
|
||||
if operation in {Operation.COPY, Operation.MOVE} and (not sources or destination is None):
|
||||
raise ValueError("copy and move require source paths and a destination")
|
||||
self._validate_inside_allowed_roots(sources, roots)
|
||||
if destination is not None:
|
||||
self._validate_inside_allowed_roots((destination,), roots)
|
||||
self._validate_protected_zone_paths(sources + ((destination,) if destination else ()), exceptions)
|
||||
requested = skill_ids or []
|
||||
unknown = [skill_id for skill_id in requested if skill_id not in self._skills]
|
||||
if unknown:
|
||||
raise ValueError(f"unknown Cook personal skill: {unknown[0]}")
|
||||
return TaskContract(
|
||||
task_id=f"COOK-{uuid.uuid4().hex[:12].upper()}",
|
||||
operation=operation,
|
||||
source_paths=sources,
|
||||
destination_path=destination,
|
||||
allowed_roots=roots,
|
||||
skill_bindings=tuple(self._skills[skill_id] for skill_id in requested),
|
||||
protected_zone_exceptions=exceptions,
|
||||
).with_hash()
|
||||
|
||||
@staticmethod
|
||||
def approve(contract: TaskContract, approved_contract_sha256: str) -> TaskContract:
|
||||
if not approved_contract_sha256 or approved_contract_sha256 != contract.contract_sha256:
|
||||
raise ValueError("approval hash does not match the current contract")
|
||||
return contract.mark_approved()
|
||||
77
src/cook_ops/skills.py
Normal file
77
src/cook_ops/skills.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Read and bind a curated subset of Odysseus personal skills for Cook."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from src.cook_ops.contracts import SkillBinding
|
||||
from src.runtime_paths import get_app_root
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _SkillSpec:
|
||||
skill_id: str
|
||||
relative_path: str
|
||||
allowed_roles: tuple[str, ...]
|
||||
|
||||
|
||||
_CURATED_SKILLS = (
|
||||
_SkillSpec(
|
||||
"directory-management-protocol",
|
||||
"data/skills/general/directory-management-protocol/SKILL.md",
|
||||
("TASK_INTAKE", "FILE_INVENTORY", "FILE_MOVE_PLANNER"),
|
||||
),
|
||||
_SkillSpec(
|
||||
"verify-and-backup-archive-files",
|
||||
"data/skills/general/verify-and-backup-archive-files/SKILL.md",
|
||||
("FILE_INVENTORY", "FILE_MOVE_PLANNER", "FILE_VERIFY"),
|
||||
),
|
||||
_SkillSpec(
|
||||
"powershell-audit-pre-flight-creation",
|
||||
"data/skills/general/powershell-audit-pre-flight-creation/SKILL.md",
|
||||
("POWERSHELL_PREFLIGHT",),
|
||||
),
|
||||
_SkillSpec(
|
||||
"safe-haven-creation-recovery",
|
||||
"data/skills/general/safe-haven-creation-recovery/SKILL.md",
|
||||
("RECOVERY_AND_QUARANTINE", "HUMAN_SPEECH_AND_HANDOFF"),
|
||||
),
|
||||
_SkillSpec(
|
||||
"odx-backup-file-verification",
|
||||
"data/skills/general/odx-backup-file-verification/SKILL.md",
|
||||
("FILE_INVENTORY", "FILE_VERIFY"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _version_from_skill(text: str, skill_id: str) -> str:
|
||||
match = re.search(r"^version:\s*['\"]?([^'\"\s]+)", text, re.MULTILINE)
|
||||
if not match:
|
||||
raise ValueError(f"Cook personal skill {skill_id!r} has no version")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def load_personal_skill_registry(app_root: Path | None = None) -> tuple[SkillBinding, ...]:
|
||||
"""Return only the five approved personal skill packs, hash-bound to bytes."""
|
||||
root = Path(app_root or get_app_root()).resolve()
|
||||
bindings: list[SkillBinding] = []
|
||||
for spec in _CURATED_SKILLS:
|
||||
path = (root / spec.relative_path).resolve()
|
||||
try:
|
||||
path.relative_to(root)
|
||||
raw = path.read_bytes()
|
||||
except (OSError, ValueError) as exc:
|
||||
raise ValueError(f"Cook personal skill unavailable: {spec.skill_id}") from exc
|
||||
text = raw.decode("utf-8")
|
||||
bindings.append(
|
||||
SkillBinding(
|
||||
skill_id=spec.skill_id,
|
||||
version=_version_from_skill(text, spec.skill_id),
|
||||
sha256=hashlib.sha256(raw).hexdigest(),
|
||||
allowed_roles=spec.allowed_roles,
|
||||
)
|
||||
)
|
||||
return tuple(bindings)
|
||||
|
|
@ -100,6 +100,53 @@ def is_local_endpoint(url: str) -> bool:
|
|||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _local_ollama_ps_url(endpoint_url: str) -> Optional[str]:
|
||||
"""Return the native Ollama /api/ps URL for a local OpenAI-compatible route.
|
||||
|
||||
Ollama's OpenAI-compatible catalog does not report the runner's effective
|
||||
context window. Restrict this bridge to the standard local Ollama port and
|
||||
an explicit /v1 path so generic local OpenAI-compatible servers keep their
|
||||
existing /slots and /models resolution behavior.
|
||||
"""
|
||||
try:
|
||||
parsed = urlparse(endpoint_url)
|
||||
path = (parsed.path or "").rstrip("/")
|
||||
if (
|
||||
parsed.scheme not in ("http", "https")
|
||||
or not parsed.netloc
|
||||
or parsed.port != 11434
|
||||
or not (path == "/v1" or path.startswith("/v1/"))
|
||||
or not is_local_endpoint(endpoint_url)
|
||||
):
|
||||
return None
|
||||
return f"{parsed.scheme}://{parsed.netloc}/api/ps"
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _local_ollama_runner_context(endpoint_url: str, model: str) -> Optional[int]:
|
||||
"""Read an exactly matched loaded Ollama runner's effective context window."""
|
||||
ps_url = _local_ollama_ps_url(endpoint_url)
|
||||
if not ps_url:
|
||||
return None
|
||||
try:
|
||||
response = httpx.get(ps_url, timeout=REQUEST_TIMEOUT)
|
||||
if not response.is_success:
|
||||
return None
|
||||
models = response.json().get("models") or []
|
||||
if not isinstance(models, list):
|
||||
return None
|
||||
for entry in models:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if model not in (entry.get("name"), entry.get("model")):
|
||||
continue
|
||||
return _model_ctx_from_entry(entry)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to query Ollama runner context for {model}: {e}")
|
||||
return None
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -400,6 +447,19 @@ def _query_context_length(endpoint_url: str, model: str) -> Tuple[int, bool]:
|
|||
api_ctx = None
|
||||
configured_kind = _configured_endpoint_kind(endpoint_url)
|
||||
|
||||
# Ollama's /v1/models catalog reports model identity, not the loaded
|
||||
# runner's effective context. A family-level static map is architecture
|
||||
# capacity, not evidence of the current runner setting. For this route,
|
||||
# only an exact loaded-model match from the native /api/ps endpoint proves
|
||||
# a budget context; no match remains intentionally unknown.
|
||||
if _local_ollama_ps_url(endpoint_url):
|
||||
runtime_ctx = _local_ollama_runner_context(endpoint_url, model)
|
||||
if runtime_ctx:
|
||||
logger.info(f"Ollama /api/ps reports context_length={runtime_ctx} for {model}")
|
||||
return runtime_ctx, True
|
||||
logger.info(f"Ollama /api/ps has no loaded context proof for {model}")
|
||||
return DEFAULT_CONTEXT, False
|
||||
|
||||
# Large OpenAI-compatible proxies can make /models expensive. If the
|
||||
# endpoint is explicitly configured as API/proxy, prefer known context
|
||||
# metadata (or the default) over downloading the full catalog.
|
||||
|
|
|
|||
|
|
@ -526,6 +526,42 @@ def _parse_misfenced_read_file_lookup(content: str, *, allow_shell_style: bool =
|
|||
return ToolBlock("read_file", path)
|
||||
|
||||
|
||||
def _parse_misfenced_literal_tool_call(content: str) -> Optional[ToolBlock]:
|
||||
"""Recover one literal-only Odysseus tool call from a Python fence.
|
||||
|
||||
Text-only local models can emit ``create_document(title="...", ...)`` in
|
||||
a ``python`` fence. Executing it as Python fails because Odysseus tool
|
||||
names are not sandbox symbols. This accepts only one direct known-tool
|
||||
call with literal keyword arguments, then uses the canonical native-call
|
||||
converter. Dynamic expressions remain ordinary Python and are not
|
||||
evaluated by this recovery path.
|
||||
"""
|
||||
try:
|
||||
module = ast.parse(content.strip(), mode="exec")
|
||||
except SyntaxError:
|
||||
return None
|
||||
if len(module.body) != 1 or not isinstance(module.body[0], ast.Expr):
|
||||
return None
|
||||
call = module.body[0].value
|
||||
if not isinstance(call, ast.Call) or not isinstance(call.func, ast.Name):
|
||||
return None
|
||||
tool_name = call.func.id.lower()
|
||||
if tool_name not in TOOL_TAGS or tool_name in _CODE_FENCE_TAGS or call.args:
|
||||
return None
|
||||
|
||||
args = {}
|
||||
for keyword in call.keywords:
|
||||
if keyword.arg is None:
|
||||
return None
|
||||
try:
|
||||
args[keyword.arg] = ast.literal_eval(keyword.value)
|
||||
except (ValueError, SyntaxError, TypeError):
|
||||
return None
|
||||
|
||||
from src.tool_schemas import function_call_to_tool_block
|
||||
return function_call_to_tool_block(tool_name, json.dumps(args))
|
||||
|
||||
|
||||
def _coerce_raw_web_query(value) -> Optional[str]:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
|
|
@ -1303,6 +1339,11 @@ def parse_tool_blocks(text: str, skip_fenced: bool = False) -> List[ToolBlock]:
|
|||
if block:
|
||||
blocks.append(block)
|
||||
continue
|
||||
if tag == "python":
|
||||
block = _parse_misfenced_literal_tool_call(content)
|
||||
if block:
|
||||
blocks.append(block)
|
||||
continue
|
||||
blocks.append(ToolBlock(tag, content))
|
||||
|
||||
# Pattern 2: [TOOL_CALL] blocks (only if no fenced blocks found)
|
||||
|
|
|
|||
|
|
@ -50,6 +50,56 @@ function _clearComposerUnlessStartupTyped(msgInput) {
|
|||
msgInput.value = '';
|
||||
}
|
||||
|
||||
function _getRuntimeContextIndicator(create = false) {
|
||||
const existing = document.getElementById('runtime-context-indicator');
|
||||
if (existing || !create) return existing;
|
||||
const anchor = document.getElementById('session-cost-display');
|
||||
if (!anchor || !anchor.parentElement) return null;
|
||||
const indicator = document.createElement('span');
|
||||
indicator.id = 'runtime-context-indicator';
|
||||
indicator.className = 'runtime-context-indicator';
|
||||
indicator.setAttribute('role', 'status');
|
||||
indicator.hidden = true;
|
||||
anchor.insertAdjacentElement('afterend', indicator);
|
||||
return indicator;
|
||||
}
|
||||
|
||||
function _clearRuntimeContextIndicator() {
|
||||
const indicator = _getRuntimeContextIndicator();
|
||||
if (!indicator) return;
|
||||
indicator.hidden = true;
|
||||
indicator.textContent = '';
|
||||
indicator.removeAttribute('title');
|
||||
indicator.removeAttribute('aria-label');
|
||||
}
|
||||
|
||||
async function _refreshRuntimeContextIndicator(sessionId, navToken = _sessionNavToken) {
|
||||
_clearRuntimeContextIndicator();
|
||||
if (!sessionId || currentSessionId !== sessionId) return;
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/session/${encodeURIComponent(sessionId)}/context_info`);
|
||||
if (!response.ok) return;
|
||||
const info = await response.json();
|
||||
if (currentSessionId !== sessionId || navToken !== _sessionNavToken) return;
|
||||
const contextLength = Number(info.context_length || 0);
|
||||
const budgetContext = Number(info.budget_context || 0);
|
||||
const source = typeof info.context_source === 'string' ? info.context_source : '';
|
||||
if (contextLength <= 0 || budgetContext <= 0 || !source) return;
|
||||
const indicator = _getRuntimeContextIndicator(true);
|
||||
if (!indicator) return;
|
||||
const contextText = contextLength.toLocaleString();
|
||||
const budgetText = budgetContext.toLocaleString();
|
||||
indicator.textContent = `ctx ${contextText} · budget ${budgetText}`;
|
||||
indicator.title = `Runtime context ${contextText}; prompt budget ${budgetText}; source ${source}`;
|
||||
indicator.setAttribute('aria-label', indicator.title);
|
||||
indicator.hidden = false;
|
||||
} catch (_) {
|
||||
if (currentSessionId === sessionId && navToken === _sessionNavToken) {
|
||||
_clearRuntimeContextIndicator();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _paintSessionLoading(chatHistory, label = 'Loading chat') {
|
||||
if (!chatHistory) return;
|
||||
if (chatRenderer.hideWelcomeScreen) chatRenderer.hideWelcomeScreen();
|
||||
|
|
@ -1916,6 +1966,7 @@ export async function selectSession(id, { keepSidebar = false, showLoading = tru
|
|||
if (currentMetaEl) {
|
||||
currentMetaEl.textContent = meta ? meta.name : 'Odysseus Chat';
|
||||
}
|
||||
void _refreshRuntimeContextIndicator(id, navToken);
|
||||
// Update model picker visibility
|
||||
updateModelPicker();
|
||||
if (window.refreshChatContextHeader) window.refreshChatContextHeader('select-session');
|
||||
|
|
@ -2203,6 +2254,7 @@ export function createDirectChat(url, modelId, endpointId, opts = {}) {
|
|||
_suppressNextSessionLoading = true;
|
||||
currentSessionId = null;
|
||||
try { window.__odysseusLastSelectedSessionId = ''; } catch (_) {}
|
||||
_clearRuntimeContextIndicator();
|
||||
Storage.remove('lastSessionId');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
document.querySelectorAll('.list-item.active-session, .session-item.active').forEach(el => {
|
||||
|
|
@ -2311,6 +2363,7 @@ export async function materializePendingSession() {
|
|||
}
|
||||
_pendingChat = null;
|
||||
currentSessionId = payload.id;
|
||||
void _refreshRuntimeContextIndicator(payload.id);
|
||||
if (!isIncognito) {
|
||||
Storage.set('lastSessionId', payload.id);
|
||||
}
|
||||
|
|
@ -2383,6 +2436,7 @@ export function setCurrentSessionId(id) {
|
|||
currentSessionId = id;
|
||||
try { window.__odysseusLastSelectedSessionId = id || ''; } catch (_) {}
|
||||
if (!id) {
|
||||
_clearRuntimeContextIndicator();
|
||||
_suppressNextSessionLoading = true;
|
||||
Storage.remove('lastSessionId');
|
||||
history.replaceState(null, '', window.location.pathname);
|
||||
|
|
|
|||
|
|
@ -3189,6 +3189,15 @@ body.bg-pattern-sparkles {
|
|||
.chat-context-compact-btn:hover {
|
||||
background: color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
.runtime-context-indicator {
|
||||
font-size: inherit;
|
||||
font-weight: 400;
|
||||
color: color-mix(in srgb, var(--fg) 48%, transparent);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
margin-left: 6px;
|
||||
}
|
||||
.runtime-context-indicator[hidden] { display: none; }
|
||||
/* Model picker — input bar drop-up */
|
||||
.model-picker-wrap {
|
||||
position: relative;
|
||||
|
|
|
|||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Odysseus test package."""
|
||||
94
tests/cli/test_mcp_cli_update.py
Normal file
94
tests/cli/test_mcp_cli_update.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from tests.helpers.cli_loader import load_script
|
||||
from tests.helpers.db_stubs import make_core_db_stub
|
||||
|
||||
|
||||
class _Server:
|
||||
def __init__(self, server_id, *, args='["old"]', env='{"OLD":"value"}', is_enabled=False):
|
||||
self.id = server_id
|
||||
self.name = "LSP Code Intelligence (Pinned)"
|
||||
self.transport = "stdio"
|
||||
self.command = "node"
|
||||
self.args = args
|
||||
self.env = env
|
||||
self.url = None
|
||||
self.is_enabled = is_enabled
|
||||
self.oauth_config = None
|
||||
self.created_at = None
|
||||
|
||||
|
||||
class _Db:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
self.commits = 0
|
||||
self.closed = False
|
||||
|
||||
def get(self, _model, server_id):
|
||||
return self.rows.get(server_id)
|
||||
|
||||
def commit(self):
|
||||
self.commits += 1
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _load(monkeypatch, rows):
|
||||
make_core_db_stub(monkeypatch, models=["McpServer"])
|
||||
cli = load_script("odysseus-mcp")
|
||||
db = _Db(rows)
|
||||
monkeypatch.setattr(cli, "SessionLocal", lambda: db)
|
||||
captured = {}
|
||||
monkeypatch.setattr(cli, "emit", lambda payload, _args: captured.update(payload))
|
||||
monkeypatch.setattr(cli, "fail", lambda message: (_ for _ in ()).throw(ValueError(message)))
|
||||
return cli, db, captured
|
||||
|
||||
|
||||
def test_update_rejects_invalid_json_shapes(monkeypatch):
|
||||
cli, _db, _captured = _load(monkeypatch, {})
|
||||
|
||||
with pytest.raises(ValueError, match="invalid args"):
|
||||
cli._parse_update_json("{bad", "args", list)
|
||||
with pytest.raises(ValueError, match="expected JSON object"):
|
||||
cli._parse_update_json("[]", "env", dict)
|
||||
|
||||
|
||||
def test_update_changes_only_explicit_fields_and_redacts_output(monkeypatch):
|
||||
target = _Server("target")
|
||||
other = _Server("other", args='["untouched"]', env='{"KEEP":"safe"}', is_enabled=True)
|
||||
cli, db, captured = _load(monkeypatch, {"target": target, "other": other})
|
||||
|
||||
cli.cmd_update(
|
||||
SimpleNamespace(
|
||||
id="target",
|
||||
args='["/opt/odysseus-lsp/node_modules/@treedy/lsp-mcp/dist/index.js"]',
|
||||
env='{"PATH":"/opt/odysseus-lsp/bin:/usr/bin"}',
|
||||
is_enabled="true",
|
||||
pretty=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert target.args == '["/opt/odysseus-lsp/node_modules/@treedy/lsp-mcp/dist/index.js"]'
|
||||
assert target.env == '{"PATH": "/opt/odysseus-lsp/bin:/usr/bin"}'
|
||||
assert target.is_enabled is True
|
||||
assert other.args == '["untouched"]'
|
||||
assert other.env == '{"KEEP":"safe"}'
|
||||
assert other.is_enabled is True
|
||||
assert db.commits == 1
|
||||
assert db.closed is True
|
||||
assert captured["env"] == {"PATH": "***"}
|
||||
|
||||
|
||||
def test_update_unknown_id_fails_closed_without_commit(monkeypatch):
|
||||
cli, db, _captured = _load(monkeypatch, {})
|
||||
|
||||
with pytest.raises(ValueError, match="no MCP server"):
|
||||
cli.cmd_update(
|
||||
SimpleNamespace(id="missing", args='[]', env=None, is_enabled=None, pretty=False)
|
||||
)
|
||||
|
||||
assert db.commits == 0
|
||||
assert db.closed is True
|
||||
5
tests/fixtures/lsp_docker/package.json
vendored
Normal file
5
tests/fixtures/lsp_docker/package.json
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "odysseus-lsp-docker-fixture",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
10
tests/fixtures/lsp_docker/python_fixture.py
vendored
Normal file
10
tests/fixtures/lsp_docker/python_fixture.py
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
def defined_symbol(value: int) -> int:
|
||||
return value + 1
|
||||
|
||||
|
||||
def use_defined_symbol() -> int:
|
||||
return defined_symbol(41)
|
||||
|
||||
|
||||
def deliberate_python_error() -> int:
|
||||
return undefined_symbol
|
||||
6
tests/fixtures/lsp_docker/ts_fixture.ts
vendored
Normal file
6
tests/fixtures/lsp_docker/ts_fixture.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function definedTsSymbol(value: number): number {
|
||||
return value + 1;
|
||||
}
|
||||
|
||||
export const useDefinedTsSymbol = definedTsSymbol(41);
|
||||
export const deliberateTypeScriptError: number = "not a number";
|
||||
10
tests/fixtures/lsp_docker/tsconfig.json
vendored
Normal file
10
tests/fixtures/lsp_docker/tsconfig.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext"
|
||||
},
|
||||
"include": ["ts_fixture.ts"]
|
||||
}
|
||||
|
|
@ -96,6 +96,14 @@ def test_browser_mcp_args_can_keep_sandbox(monkeypatch):
|
|||
assert "--no-sandbox" not in args
|
||||
|
||||
|
||||
def test_builtin_browser_mcp_uses_the_pinned_playwright_package(monkeypatch):
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
|
||||
assert builtin_mcp._BUILTIN_NPX_SERVERS["builtin_browser"]["args"] == [
|
||||
"-y", "@playwright/mcp@0.0.78", "--headless", "--caps", "vision"
|
||||
]
|
||||
|
||||
|
||||
def test_npx_cache_check_detects_scoped_package_in_npx_cache(monkeypatch, tmp_path):
|
||||
builtin_mcp = _load_builtin_mcp(monkeypatch)
|
||||
package_json = (
|
||||
|
|
@ -144,6 +152,7 @@ def test_npx_cache_check_falls_back_when_async_subprocess_is_unsupported(monkeyp
|
|||
monkeypatch.setattr(builtin_mcp.subprocess, "run", fake_run)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.delenv("npm_config_cache", raising=False)
|
||||
monkeypatch.delenv("LOCALAPPDATA", raising=False)
|
||||
|
||||
assert asyncio.run(
|
||||
builtin_mcp._is_npx_package_cached(
|
||||
|
|
@ -175,6 +184,7 @@ def test_npx_cache_check_fallback_treats_timeout_as_cache_miss(monkeypatch, tmp_
|
|||
monkeypatch.setattr(builtin_mcp.subprocess, "run", fake_run)
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.delenv("npm_config_cache", raising=False)
|
||||
monkeypatch.delenv("LOCALAPPDATA", raising=False)
|
||||
|
||||
assert asyncio.run(
|
||||
builtin_mcp._is_npx_package_cached(
|
||||
|
|
|
|||
133
tests/test_cook_ops_contracts.py
Normal file
133
tests/test_cook_ops_contracts.py
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
"""Behavioral contract tests for the bounded Odysseus Cook controller."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.cook_ops.contracts import Operation, TaskContract, TaskStatus
|
||||
from src.cook_ops.controller import CookController
|
||||
from src.cook_ops.skills import load_personal_skill_registry
|
||||
|
||||
|
||||
def test_registry_exposes_five_curated_personal_skills_with_hashes():
|
||||
registry = load_personal_skill_registry()
|
||||
|
||||
assert len(registry) == 5
|
||||
assert {skill.skill_id for skill in registry} == {
|
||||
"directory-management-protocol",
|
||||
"verify-and-backup-archive-files",
|
||||
"powershell-audit-pre-flight-creation",
|
||||
"safe-haven-creation-recovery",
|
||||
"odx-backup-file-verification",
|
||||
}
|
||||
assert all(len(skill.sha256) == 64 for skill in registry)
|
||||
assert all(skill.allowed_roles for skill in registry)
|
||||
|
||||
|
||||
def test_proposed_copy_contract_is_hash_bound_and_not_approved_by_default(tmp_path: Path):
|
||||
source = tmp_path / "source.txt"
|
||||
destination = tmp_path / "destination"
|
||||
source.write_text("fixture", encoding="utf-8")
|
||||
destination.mkdir()
|
||||
|
||||
controller = CookController(skill_registry=load_personal_skill_registry())
|
||||
contract = controller.propose(
|
||||
operation=Operation.COPY,
|
||||
source_paths=[source],
|
||||
destination_path=destination,
|
||||
allowed_roots=[tmp_path],
|
||||
skill_ids=["verify-and-backup-archive-files"],
|
||||
)
|
||||
|
||||
assert contract.status is TaskStatus.PROPOSED
|
||||
assert contract.contract_sha256
|
||||
assert not contract.approved
|
||||
assert contract.skill_bindings[0].skill_id == "verify-and-backup-archive-files"
|
||||
|
||||
|
||||
def test_approval_requires_the_current_contract_hash(tmp_path: Path):
|
||||
source = tmp_path / "source.txt"
|
||||
destination = tmp_path / "destination"
|
||||
source.write_text("fixture", encoding="utf-8")
|
||||
destination.mkdir()
|
||||
controller = CookController(skill_registry=load_personal_skill_registry())
|
||||
contract = controller.propose(
|
||||
operation=Operation.COPY,
|
||||
source_paths=[source],
|
||||
destination_path=destination,
|
||||
allowed_roots=[tmp_path],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="hash"):
|
||||
controller.approve(contract, "0" * 64)
|
||||
|
||||
approved = controller.approve(contract, contract.contract_sha256)
|
||||
assert approved.approved
|
||||
assert approved.status is TaskStatus.READY_FOR_APPROVAL
|
||||
|
||||
|
||||
def test_contract_rejects_a_source_outside_its_allowed_root(tmp_path: Path):
|
||||
source = tmp_path / "source.txt"
|
||||
source.write_text("fixture", encoding="utf-8")
|
||||
destination = tmp_path / "destination"
|
||||
destination.mkdir()
|
||||
controller = CookController(skill_registry=load_personal_skill_registry())
|
||||
|
||||
with pytest.raises(ValueError, match="allowed roots"):
|
||||
controller.propose(
|
||||
operation=Operation.COPY,
|
||||
source_paths=[source],
|
||||
destination_path=destination,
|
||||
allowed_roots=[tmp_path / "different-root"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("source", "destination"),
|
||||
[
|
||||
(Path(r"G:\AIW\00_IN\incoming.txt"), Path(r"G:\AIW\04_OUT\safe-destination")),
|
||||
(Path(r"G:\AIW\04_OUT\safe-source.txt"), Path(r"G:\AIW\05_BAK\archived.txt")),
|
||||
],
|
||||
)
|
||||
def test_proposal_rejects_protected_zone_paths_without_an_exact_exception(
|
||||
source: Path, destination: Path
|
||||
):
|
||||
controller = CookController(skill_registry=load_personal_skill_registry())
|
||||
|
||||
with pytest.raises(ValueError, match="protected zone"):
|
||||
controller.propose(
|
||||
operation=Operation.COPY,
|
||||
source_paths=[source],
|
||||
destination_path=destination,
|
||||
allowed_roots=[Path(r"G:\AIW")],
|
||||
)
|
||||
|
||||
|
||||
def test_protected_zone_exception_must_match_exact_path_and_bind_to_approval_hash():
|
||||
controller = CookController(skill_registry=load_personal_skill_registry())
|
||||
source = Path(r"G:\AIW\00_IN\incoming.txt")
|
||||
destination = Path(r"G:\AIW\04_OUT\safe-destination")
|
||||
|
||||
with pytest.raises(ValueError, match="protected zone"):
|
||||
controller.propose(
|
||||
operation=Operation.COPY,
|
||||
source_paths=[source],
|
||||
destination_path=destination,
|
||||
allowed_roots=[Path(r"G:\AIW")],
|
||||
protected_zone_exceptions=[Path(r"G:\AIW\00_IN")],
|
||||
)
|
||||
|
||||
contract = controller.propose(
|
||||
operation=Operation.COPY,
|
||||
source_paths=[source],
|
||||
destination_path=destination,
|
||||
allowed_roots=[Path(r"G:\AIW")],
|
||||
protected_zone_exceptions=[source],
|
||||
)
|
||||
|
||||
assert contract.status is TaskStatus.PROPOSED
|
||||
assert contract.protected_zone_exceptions == (source.resolve(),)
|
||||
assert contract.canonical_payload()["protected_zone_exceptions"] == [str(source.resolve())]
|
||||
with pytest.raises(ValueError, match="hash"):
|
||||
controller.approve(contract, "0" * 64)
|
||||
assert controller.approve(contract, contract.contract_sha256).approved
|
||||
87
tests/test_cook_ops_file_flow.py
Normal file
87
tests/test_cook_ops_file_flow.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Fixture-only file and PowerShell vertical slices for Odysseus Cook."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from src.cook_ops.agents.file_inventory import run as inventory
|
||||
from src.cook_ops.agents.file_move_planner import run as plan_moves
|
||||
from src.cook_ops.agents.file_mover import run as move
|
||||
from src.cook_ops.agents.file_verify import run as verify
|
||||
from src.cook_ops.agents.powershell_executor import run as execute_powershell
|
||||
from src.cook_ops.agents.powershell_preflight import run as preflight
|
||||
from src.cook_ops.contracts import Operation
|
||||
from src.cook_ops.controller import CookController
|
||||
from src.cook_ops.skills import load_personal_skill_registry
|
||||
|
||||
|
||||
def _contract(tmp_path: Path, operation: Operation):
|
||||
source = tmp_path / "source.txt"
|
||||
source.write_text("fixture", encoding="utf-8")
|
||||
destination = tmp_path / "destination"
|
||||
destination.mkdir()
|
||||
return CookController(load_personal_skill_registry()).propose(
|
||||
operation=operation,
|
||||
source_paths=[source],
|
||||
destination_path=destination,
|
||||
allowed_roots=[tmp_path],
|
||||
)
|
||||
|
||||
|
||||
def _approve(contract):
|
||||
return CookController(load_personal_skill_registry()).approve(
|
||||
contract, contract.contract_sha256
|
||||
)
|
||||
|
||||
|
||||
def test_copy_transaction_is_hash_verified_and_preserves_source(tmp_path: Path):
|
||||
contract = _approve(_contract(tmp_path, Operation.COPY))
|
||||
manifest = inventory(contract)
|
||||
plan = plan_moves(contract, manifest)
|
||||
receipts = move(contract, plan)
|
||||
result = verify(contract, plan)
|
||||
|
||||
assert receipts[0]["status"] == "VERIFIED"
|
||||
assert result["status"] == "VERIFIED"
|
||||
assert contract.source_paths[0].exists()
|
||||
assert (contract.destination_path / "source.txt").read_text(encoding="utf-8") == "fixture"
|
||||
|
||||
|
||||
def test_unapproved_copy_plan_cannot_mutate_the_destination(tmp_path: Path):
|
||||
contract = _contract(tmp_path, Operation.COPY)
|
||||
manifest = inventory(contract)
|
||||
plan = plan_moves(contract, manifest)
|
||||
|
||||
receipts = move(contract, plan)
|
||||
|
||||
assert receipts[0]["status"] == "BLOCKED"
|
||||
assert contract.destination_path is not None
|
||||
assert not (contract.destination_path / "source.txt").exists()
|
||||
|
||||
|
||||
def test_different_content_collision_blocks_before_move(tmp_path: Path):
|
||||
contract = _contract(tmp_path, Operation.COPY)
|
||||
(contract.destination_path / "source.txt").write_text("different", encoding="utf-8")
|
||||
|
||||
manifest = inventory(contract)
|
||||
plan = plan_moves(contract, manifest)
|
||||
|
||||
assert plan["status"] == "BLOCKED"
|
||||
assert contract.source_paths[0].exists()
|
||||
|
||||
|
||||
def test_powershell_preflight_rejects_dangerous_or_invalid_script(tmp_path: Path):
|
||||
contract = _contract(tmp_path, Operation.POWERSHELL)
|
||||
|
||||
invalid = preflight(contract, "Write-Output 'unterminated")
|
||||
forbidden = preflight(contract, "New-Service -Name bad -BinaryPathName x")
|
||||
|
||||
assert invalid["status"] == "BLOCKED"
|
||||
assert forbidden["status"] == "BLOCKED"
|
||||
|
||||
|
||||
def test_unapproved_powershell_contract_cannot_execute(tmp_path: Path):
|
||||
contract = _contract(tmp_path, Operation.POWERSHELL)
|
||||
script = "Write-Output 'safe'"
|
||||
|
||||
result = execute_powershell(contract, script, "0" * 64)
|
||||
|
||||
assert result["status"] == "BLOCKED"
|
||||
|
|
@ -110,6 +110,16 @@ def test_docker_entrypoint_ownership_repair_stays_inside_expected_mounts():
|
|||
assert "Skipping recursive ownership repair" in script
|
||||
|
||||
|
||||
def test_dockerfile_locks_browser_mcp_and_chrome_prerequisites():
|
||||
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
|
||||
|
||||
assert "ARG GOOGLE_CHROME_VERSION=151.0.7922.108-1" in dockerfile
|
||||
assert "google-chrome-stable=${GOOGLE_CHROME_VERSION}" in dockerfile
|
||||
assert "npm_config_cache=/app/.npm" in dockerfile
|
||||
assert "npx -y @playwright/mcp@0.0.78 --version" in dockerfile
|
||||
assert "gosu odysseus env HOME=/app" in dockerfile
|
||||
|
||||
|
||||
def test_dockerignore_excludes_secrets_editor_backups():
|
||||
patterns = set((ROOT / ".dockerignore").read_text(encoding="utf-8").splitlines())
|
||||
assert {
|
||||
|
|
|
|||
12
tests/test_mcp_dependency_pin.py
Normal file
12
tests/test_mcp_dependency_pin.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""Regression coverage for the built-in MCP server API contract."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_requirements_pin_the_mcp_v1_server_decorator_api():
|
||||
requirements = (REPO_ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines()
|
||||
|
||||
assert "mcp==1.28.1" in requirements
|
||||
24
tests/test_mcp_inventory_routing.py
Normal file
24
tests/test_mcp_inventory_routing.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Regression for MCP server inventory routing on local text-only models.
|
||||
|
||||
A request to list installed MCP servers must use the native manage_mcp inventory
|
||||
path. It must not expose unrelated browser MCP schemas that invite fabricated
|
||||
browser navigation.
|
||||
"""
|
||||
|
||||
from src.agent_loop import _is_mcp_server_inventory_request
|
||||
|
||||
|
||||
def test_installed_mcps_servers_is_an_inventory_request():
|
||||
assert _is_mcp_server_inventory_request("what mcps servers are installed in odysseus")
|
||||
|
||||
|
||||
def test_list_connected_mcp_servers_is_an_inventory_request():
|
||||
assert _is_mcp_server_inventory_request("list the connected MCP servers")
|
||||
|
||||
|
||||
def test_browser_request_is_not_an_mcp_inventory_request():
|
||||
assert not _is_mcp_server_inventory_request("open this website in the browser")
|
||||
|
||||
|
||||
def test_mcp_tool_request_is_not_server_inventory_request():
|
||||
assert not _is_mcp_server_inventory_request("what MCP tools can the browser server use")
|
||||
42
tests/test_misfenced_known_tools.py
Normal file
42
tests/test_misfenced_known_tools.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
"""Regression coverage for local models emitting tool-shaped Python."""
|
||||
|
||||
import src.agent_tools # noqa: F401 (initializes ToolBlock before parser import)
|
||||
from src.tool_parsing import parse_tool_blocks
|
||||
|
||||
|
||||
def test_python_fence_with_literal_known_tool_call_uses_named_tool():
|
||||
response = '''```python
|
||||
create_document(
|
||||
title="Modelfile Documentation",
|
||||
language="markdown",
|
||||
content="# Odysseus"
|
||||
)
|
||||
```'''
|
||||
|
||||
blocks = parse_tool_blocks(response)
|
||||
|
||||
assert [(block.tool_type, block.content) for block in blocks] == [
|
||||
("create_document", "Modelfile Documentation\nmarkdown\n# Odysseus")
|
||||
]
|
||||
|
||||
|
||||
def test_python_fence_with_dynamic_known_tool_call_remains_python():
|
||||
response = '''```python
|
||||
create_document(title=title, content=build_content())
|
||||
```'''
|
||||
|
||||
blocks = parse_tool_blocks(response)
|
||||
|
||||
assert [(block.tool_type, block.content) for block in blocks] == [
|
||||
("python", "create_document(title=title, content=build_content())")
|
||||
]
|
||||
|
||||
|
||||
def test_ordinary_python_fence_remains_python():
|
||||
response = '''```python
|
||||
print("hello")
|
||||
```'''
|
||||
|
||||
blocks = parse_tool_blocks(response)
|
||||
|
||||
assert [(block.tool_type, block.content) for block in blocks] == [("python", 'print("hello")')]
|
||||
|
|
@ -205,6 +205,65 @@ class TestGetContextLength:
|
|||
model_context._context_cache.clear()
|
||||
model_context._catalog_ctx_cache.clear()
|
||||
|
||||
@pytest.mark.parametrize("model_field", ["name", "model"])
|
||||
def test_local_ollama_v1_uses_exact_loaded_runner_context(self, monkeypatch, model_field):
|
||||
calls = []
|
||||
|
||||
def fake_get(url, *args, **kwargs):
|
||||
calls.append(url)
|
||||
if url == "http://host.docker.internal:11434/api/ps":
|
||||
return _FakeResp({"models": [{
|
||||
model_field: "qwen3-coder:30b",
|
||||
"context_length": 4096,
|
||||
}]})
|
||||
raise AssertionError(f"Unexpected context probe: {url}")
|
||||
|
||||
monkeypatch.setattr(model_context.httpx, "get", fake_get)
|
||||
|
||||
assert model_context._query_context_length(
|
||||
"http://host.docker.internal:11434/v1", "qwen3-coder:30b"
|
||||
) == (4096, True)
|
||||
assert calls == ["http://host.docker.internal:11434/api/ps"]
|
||||
|
||||
def test_local_ollama_v1_without_loaded_runner_context_stays_unknown(self, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_get(url, *args, **kwargs):
|
||||
calls.append(url)
|
||||
if url == "http://host.docker.internal:11434/api/ps":
|
||||
return _FakeResp({"models": []})
|
||||
raise AssertionError(f"Unexpected context probe: {url}")
|
||||
|
||||
monkeypatch.setattr(model_context.httpx, "get", fake_get)
|
||||
|
||||
assert model_context._query_context_length(
|
||||
"http://host.docker.internal:11434/v1", "qwen3-coder:30b"
|
||||
) == (model_context.DEFAULT_CONTEXT, False)
|
||||
assert calls == ["http://host.docker.internal:11434/api/ps"]
|
||||
assert model_context.budget_context_for_model(
|
||||
"http://host.docker.internal:11434/v1", "qwen3-coder:30b"
|
||||
) == 0
|
||||
assert calls == [
|
||||
"http://host.docker.internal:11434/api/ps",
|
||||
"http://host.docker.internal:11434/api/ps",
|
||||
]
|
||||
|
||||
def test_generic_local_v1_endpoint_keeps_slots_resolution_without_ollama_ps_probe(self, monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_get(url, *args, **kwargs):
|
||||
calls.append(url)
|
||||
if url == "http://localhost:8080/slots":
|
||||
return _FakeResp([{"n_ctx": 8192}])
|
||||
raise AssertionError(f"Unexpected context probe: {url}")
|
||||
|
||||
monkeypatch.setattr(model_context.httpx, "get", fake_get)
|
||||
|
||||
assert model_context._query_context_length(
|
||||
"http://localhost:8080/v1", "qwen3-coder:30b"
|
||||
) == (8192, True)
|
||||
assert calls == ["http://localhost:8080/slots"]
|
||||
|
||||
def test_local_endpoint_requeries_same_model_after_restart(self, monkeypatch):
|
||||
calls = []
|
||||
|
||||
|
|
|
|||
100
tests/test_runtime_context_indicator.py
Normal file
100
tests/test_runtime_context_indicator.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Regression coverage for the visible runtime-context indicator contract."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
import routes.session_routes as session_routes
|
||||
import src.model_context as model_context
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _context_info_endpoint(monkeypatch, session):
|
||||
monkeypatch.setattr(
|
||||
session_routes,
|
||||
"router",
|
||||
APIRouter(prefix="/api", tags=["sessions"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
session_routes,
|
||||
"_verify_session_owner",
|
||||
lambda request, session_id: None,
|
||||
)
|
||||
manager = MagicMock()
|
||||
manager.get_session.return_value = session
|
||||
router = session_routes.setup_session_routes(manager, {})
|
||||
return next(
|
||||
route.endpoint
|
||||
for route in router.routes
|
||||
if getattr(route, "path", "") == "/api/session/{session_id}/context_info"
|
||||
)
|
||||
|
||||
|
||||
def test_context_info_reports_proven_ollama_runtime_budget(monkeypatch):
|
||||
endpoint = _context_info_endpoint(
|
||||
monkeypatch,
|
||||
SimpleNamespace(
|
||||
endpoint_url="http://host.docker.internal:11434/v1",
|
||||
model="qwen3-coder:30b",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
model_context,
|
||||
"get_context_length_known",
|
||||
lambda endpoint_url, model: (4096, True),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
model_context,
|
||||
"_local_ollama_ps_url",
|
||||
lambda endpoint_url: "http://host.docker.internal:11434/api/ps",
|
||||
)
|
||||
|
||||
assert asyncio.run(endpoint(request=MagicMock(), session_id="session-1")) == {
|
||||
"context_length": 4096,
|
||||
"model": "qwen3-coder:30b",
|
||||
"budget_context": 4096,
|
||||
"context_source": "ollama_api_ps",
|
||||
}
|
||||
|
||||
|
||||
def test_context_info_hides_unproven_context_from_the_ui(monkeypatch):
|
||||
endpoint = _context_info_endpoint(
|
||||
monkeypatch,
|
||||
SimpleNamespace(
|
||||
endpoint_url="http://host.docker.internal:11434/v1",
|
||||
model="qwen3-coder:30b",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
model_context,
|
||||
"get_context_length_known",
|
||||
lambda endpoint_url, model: (model_context.DEFAULT_CONTEXT, False),
|
||||
)
|
||||
|
||||
assert asyncio.run(endpoint(request=MagicMock(), session_id="session-1")) == {
|
||||
"context_length": None,
|
||||
"model": "qwen3-coder:30b",
|
||||
"budget_context": 0,
|
||||
"context_source": None,
|
||||
}
|
||||
|
||||
|
||||
def test_runtime_context_indicator_has_race_safe_session_wiring():
|
||||
sessions_js = (ROOT / "static" / "js" / "sessions.js").read_text(encoding="utf-8")
|
||||
style_css = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
assert "function _getRuntimeContextIndicator" in sessions_js
|
||||
assert "document.createElement('span')" in sessions_js
|
||||
assert "runtime-context-indicator" in sessions_js
|
||||
assert "function _refreshRuntimeContextIndicator" in sessions_js
|
||||
assert "/context_info" in sessions_js
|
||||
assert "navToken !== _sessionNavToken" in sessions_js
|
||||
assert "function _clearRuntimeContextIndicator" in sessions_js
|
||||
assert "context_source" in sessions_js
|
||||
assert ".runtime-context-indicator" in style_css
|
||||
assert ".runtime-context-indicator[hidden]" in style_css
|
||||
Loading…
Add table
Add a link
Reference in a new issue