This commit is contained in:
jtevesnz-sys 2026-08-09 19:00:14 +12:00 committed by GitHub
commit 30cc95824e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
50 changed files with 3052 additions and 16 deletions

View file

@ -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)

View 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.
```

View 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.
```

View 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.

View file

@ -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.

View file

@ -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
View 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

File diff suppressed because it is too large Load diff

View 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"
}
}

View 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())

View file

@ -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

View file

@ -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

View file

@ -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")

View file

@ -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")]

View file

@ -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
View file

@ -0,0 +1 @@
"""Odysseus Cook: bounded, contract-driven basic-task operations."""

View file

@ -0,0 +1 @@
"""The ten bounded Cook worker entry points."""

View 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}

View file

@ -0,0 +1 @@
def run(contract, inventory, rules): return {"status": "BLOCKED" if not rules else "VERIFIED", "rules": tuple(rules)}

View 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}

View 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}

View 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

View 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"}

View 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}

View 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)

View 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()}

View 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."}

View 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
View 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)

View 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
View 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)

View file

@ -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.

View file

@ -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)

View file

@ -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);

View file

@ -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
View file

@ -0,0 +1 @@
"""Odysseus test package."""

View 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

View file

@ -0,0 +1,5 @@
{
"name": "odysseus-lsp-docker-fixture",
"private": true,
"type": "module"
}

View 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

View 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
View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"noEmit": true,
"strict": true,
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext"
},
"include": ["ts_fixture.ts"]
}

View file

@ -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(

View 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

View 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"

View file

@ -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 {

View 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

View 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")

View 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")')]

View file

@ -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 = []

View 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