Compare commits

..
Author SHA1 Message Date
Alexandre Teixeira 7937f87e54 Revert "fix(chats): preserve saved conversations during tidy" 2026-10-30 15:34:21 +00:00
Alexandre Teixeira 0e49c4ef40 Merge pull request #6978 from odysseus-dev/test/remediation-false-green-oracles
test: reject vacuous success in email, bridge, auth and deletion tests
2026-10-30 15:25:10 +00:00
Alexandre Teixeira 1aff71836b Merge pull request #6611 from robyroro/fix/chat-tidy-preserve-content
fix(chats): preserve saved conversations during tidy
2026-10-30 15:09:09 +00:00
Alexandre Teixeira b1c3125865 test: accept positional arguments in task endpoint mock for auto-sort 2026-10-30 15:00:08 +00:00
Alexandre Teixeira dbee1155e6 Merge pull request #6080 from alteixeira20/fix/cookbook-stale-llama-server-shim
fix(cookbook): recover from stale llama-server shims
2026-10-30 14:13:15 +00:00
Alexandre Teixeira 56c7cdf804 Merge pull request #6544 from harshit-ojha0324/fix/mcp-http-connect-leak
fix(mcp): close the HTTP transport when a remote connect fails
2026-10-30 14:09:43 +00:00
Alexandre Teixeira fe5d5caf34 Merge pull request #6977 from odysseus-dev/test/remediation-execution-isolation
test: fix pytest schema loss across route-client threads
2026-10-30 13:59:20 +00:00
Alexandre Teixeira bb654482f3 Merge pull request #6774 from odysseus-dev/fix/ios-safe-area-postmerge-hardening
fix(ui): harden iOS viewport and safe-area behavior after #6396
2026-10-30 13:56:25 +00:00
Alexandre Teixeira f45724faed Merge pull request #6983 from odysseus-dev/test/remediation-auth-fixture-seed
perf(tests): reuse immutable auth setup with fresh per-case state
2026-10-30 13:42:10 +00:00
Alexandre Teixeira 6fc6095aec test(e2e): harden mobile layout coverage, artifact isolation, and live pane assertions
- Store Playwright evidence in ignored test-results/ paths by default while preserving env override
- Clean up disposable temporary application data directories in /tmp on global teardown and exit signals
- Add Chromium mobile and tablet viewport profiles (phone portrait/landscape, tablet) for automated coverage
- Add a scoped Chromium mobile layout CI check on shard 1/4 to prevent redundant matrix execution
- Reach mobile Settings and Notes views via genuine sidebar navigation controls opened through the hamburger button
- Add assertions against live rendered Notes and Documents panes honoring safe-area top and height constraints
- Document local WebKit prerequisites and physical iOS/PWA test limitations in tests/e2e/README.md and package.json
2026-10-30 13:41:15 +00:00
Alexandre Teixeira ef16398c5c merge(dev): integrate latest dev into fix/ios-safe-area-postmerge-hardening 2026-10-30 13:25:33 +00:00
Alexandre Teixeira 6037707430 fix(cookbook): launch validated llama-server candidate and handle PATH precedence
Ensure the native candidate that passed the health check is prioritized and executed when an earlier broken candidate exists on PATH.

Reconcile Odysseus-owned shims safely, invalidate command hash cache, and verify launchability rather than command existence before serve dispatch.

Fixes #6079
2026-10-30 13:15:40 +00:00
Alexandre Teixeira 5c542d03ac Merge remote-tracking branch 'origin/dev' into fix/pr-6080-llama-recovery 2026-10-30 13:02:47 +00:00
Alexandre Teixeira 3b8c60ac1e test: check database ownership against the private temp namespace 2026-10-29 17:49:45 +00:00
Alexandre Teixeira d237c3fbba perf(tests): reuse immutable admin auth configuration 2026-10-29 17:35:59 +00:00
Alexandre Teixeira fe81c491cd test: give collection database URL one cleanup owner 2026-10-29 17:30:10 +00:00
Alexandre Teixeira 6f5c28b345 test: require real cold reads, cancellation and auth writes 2026-10-29 17:12:20 +00:00
Alexandre Teixeira aa2db8d87c test: keep worker collection databases consistent across threads 2026-10-29 17:12:14 +00:00
PR #6029 Reviewer 7fd4e0e9bb fix(ui): harden iOS safe-area and viewport regressions 2026-10-20 11:39:51 +01:00
robyroro ad3e309074 fix(chats): preserve saved conversations during tidy 2026-10-08 06:48:41 +03:00
harshit-ojha0324 50b05bf54f fix(mcp): close the HTTP transport when a remote connect fails
_connect_http entered streamablehttp_client and ClientSession on an
AsyncExitStack but never closed it when initialize or list_tools
failed. The half-open transport was left to the garbage collector,
which exited its AnyIO cancel scope from the wrong task, and the event
loop kept spinning: about 18% of a core per failed connect, growing
with each one, until restart.

Close the stack in the connecting task on any failure or cancellation,
the same way _connect_sse already does.

Fixes #5518
2026-10-06 19:01:10 -04:00
Alexandre Teixeira a7c03f85bf fix(cookbook): recover from stale llama-server shims
Reconcile Odysseus-owned Python compatibility shims when llama_cpp.server is no longer available, allowing healthy native llama-server binaries in PATH to be discovered and executed without shadowing.

Fixes #6079
2026-08-17 00:59:13 +01:00
30 changed files with 1607 additions and 744 deletions
-17
View File
@@ -1,17 +0,0 @@
"""Reject skipped/empty native CI runs without conflating driver child counts."""
import argparse
from pathlib import Path
import xml.etree.ElementTree as ET
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('report', type=Path)
parser.add_argument('--minimum', type=int, required=True)
args = parser.parse_args()
root = ET.parse(args.report).getroot()
cases = list(root.iter('testcase'))
if len(cases) < args.minimum:
raise SystemExit(f'Incomplete execution: {len(cases)} cases, expected at least {args.minimum}')
for case in cases:
if any(case.find(tag) is not None for tag in ('skipped', 'failure', 'error')):
raise SystemExit(f'Case did not pass: {case.get("classname")}::{case.get("name")}')
print(f'Verified {len(cases)} executed cases; no skipped, failed or errored cases')
-46
View File
@@ -1,46 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { parse } from 'acorn';
const root = fileURLToPath(new URL('../../', import.meta.url));
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'tests/native-ci-manifest.json'), 'utf8'));
function files(directory) {
return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
const target = path.join(directory, entry.name);
return entry.isDirectory() ? files(target) : /\.(?:mjs|cjs|js)$/.test(entry.name) ? [target] : [];
});
}
function importsNativeTest(node) {
if (!node || typeof node !== 'object') return false;
if (node.type === 'ImportDeclaration' && node.source.value === 'node:test') return true;
if (node.type === 'CallExpression' && node.callee.name === 'require' && node.arguments[0]?.value === 'node:test') return true;
return Object.values(node).some(value => Array.isArray(value) ? value.some(importsNativeTest) : importsNativeTest(value));
}
const discovered = files(path.join(root, 'tests')).filter(file => {
const source = fs.readFileSync(file, 'utf8');
return source.includes('node:test') && importsNativeTest(parse(source, { ecmaVersion: 'latest', sourceType: 'module' }));
}).map(file => path.relative(root, file).split(path.sep).join('/')).sort();
const assigned = Object.values(manifest).flatMap(group => Array.isArray(group) ? group : Object.keys(group));
if (new Set(assigned).size !== assigned.length || JSON.stringify([...assigned].sort()) !== JSON.stringify(discovered)) {
throw new Error('Native test coverage must be exhaustive and disjoint; update tests/native-ci-manifest.json for added, removed or duplicated files');
}
for (const wrappers of Object.values(manifest['python-ci'])) {
for (const wrapper of wrappers) if (!fs.existsSync(path.join(root, wrapper))) throw new Error(`Missing pytest wrapper: ${wrapper}`);
}
if (process.argv.includes('--list')) {
console.log(JSON.stringify({ discovered, runners: manifest }, null, 2));
} else {
const reports = path.join(root, 'test-results/native-node');
fs.mkdirSync(reports, { recursive: true });
const result = spawnSync(process.execPath, ['--test', '--test-concurrency=1',
'--test-reporter=spec', '--test-reporter-destination=stdout',
'--test-reporter=junit', `--test-reporter-destination=${path.join(reports, 'junit.xml')}`,
...manifest['native-ci']], {
cwd: root, stdio: 'inherit',
env: { ...process.env, ENDPOINT_URL: 'http://127.0.0.1:1' },
});
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
}
+7 -1
View File
@@ -4,7 +4,6 @@ on:
push:
branches: [main, dev]
pull_request:
workflow_dispatch:
# Least privilege: none of the jobs write to the repo.
permissions:
@@ -257,3 +256,10 @@ jobs:
env:
PYTEST_SHARD: ${{ matrix.shard }}
run: python -m pytest -q -rs --durations=25 --shard "$PYTEST_SHARD"
- name: Playwright mobile layout checks (shard 1/4 only)
if: steps.docs-check.outputs.docs_only != 'true' && matrix.shard == '1/4'
run: |
ODYSSEUS_TEST_PYTHON=python ODYSSEUS_LAYOUT_BROWSER=chromium npx --no-install playwright test \
--config tests/e2e/ios-layout.config.cjs \
--reporter=line
-119
View File
@@ -1,119 +0,0 @@
name: Native test suites
on:
pull_request:
push:
branches: [main, dev, test/remediation-integration-validation]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: native-tests-${{ github.ref }}
cancel-in-progress: true
jobs:
native-node:
name: Native Node regressions
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.23.4'
cache: npm
- run: npm ci --ignore-scripts --no-audit --no-fund
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium
- name: Validate exhaustive disjoint runner mapping and execute omitted suites
run: |
node .github/scripts/run_native_tests.mjs
python .github/scripts/check_test_execution.py test-results/native-node/junit.xml --minimum 43
- name: Save native test identities and results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: native-node-results
path: test-results/native-node/
retention-days: 7
if-no-files-found: warn
browser:
name: Chromium application regressions
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS: '1'
ODYSSEUS_TEST_PYTHON: python
CHROMADB_HOST: 127.0.0.1
CHROMADB_PORT: '1'
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.11'
- name: Install FFmpeg and establish system trust
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends ffmpeg ca-certificates
python scripts/check_ca_trust.py --system
- uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
version: '0.13.5'
enable-cache: true
cache-dependency-glob: requirements.txt
prune-cache: false
- run: |
uv pip install --system --require-hashes --only-binary=:all: --no-deps -r requirements-bootstrap.txt
python scripts/check_ca_trust.py --python
uv pip install --system -r requirements.txt
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22.23.4'
cache: npm
- run: npm ci --ignore-scripts --no-audit --no-fund
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: ms-playwright-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
- run: npx playwright install --with-deps chromium
- name: Run complete Chromium suite against a fresh authenticated app
env:
PLAYWRIGHT_JUNIT_OUTPUT_FILE: test-results/browser-junit.xml
run: |
npm run test:photo-editor -- --reporter=line,junit
python .github/scripts/check_test_execution.py test-results/browser-junit.xml --minimum 130
- name: Save per-test results and sanitized failure diagnostics
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: chromium-regression-results
path: test-results/
retention-days: 7
if-no-files-found: warn
authoritative-native-suites:
name: Authoritative native suites
if: always()
needs: [native-node, browser]
runs-on: ubuntu-24.04
steps:
- name: Require both complete suites to succeed
env:
NODE_RESULT: ${{ needs.native-node.result }}
BROWSER_RESULT: ${{ needs.browser.result }}
run: |
test "$NODE_RESULT" = success
test "$BROWSER_RESULT" = success
-38
View File
@@ -1,38 +0,0 @@
# Native test merge gate
`Native test suites` runs on pull requests, dev/main pushes and manual dispatch.
`Authoritative native suites` fails unless both the native Node and complete
Chromium jobs succeed; it has no continue-on-error, retries or path bypass.
Repository administrators must require this check alongside the four pytest
checks in branch protection. The audited public dev branch had no protection or
ruleset, so merely adding workflow jobs cannot prevent a manual merge.
`tests/native-ci-manifest.json` exhaustively partitions all native Node files:
16 run in the native job,14 through existing pytest wrappers, one through the
existing frontend architecture CI step, and the formerly live email-reply file
through an explicitly identified Playwright driver. The manifest validator fails
for omitted/duplicate/unreachable entries. Node emits individual JUnit identities
and propagates its failure exit. No native file is newly run in each pytest shard.
The browser job uses the existing one-worker Chromium config and all original129
cases, plus one driver for the native email-reply contract. Its ephemeral account,
SQLite DB and server are created by the config/setup; reuseExistingServer remains
false. NPX startup is offline/cache-only in a private cache; Notes/editor behavior
and Python MCP services remain real. No developer app or external account is used.
Default CI trace suppression remains intact. Only this job's explicit opt-in plus
a newly generated authenticated synthetic fixture marker permits failure traces.
The existing structured sanitizer runs before artifacts are uploaded; invalid
ownership, failed sanitization and missing tooling purge sensitive artifacts.
Never upload the fixture directory, database or browser storage state.
The stroke budget now excludes measured synthetic event-delivery pacing and also
bounds actual Chromium main-thread work at the original5s. All240 samples and
pixel/thumbnail/undo/redo assertions remain. Per-case timing attachments expose
both clocks rather than treating driver frame cadence as application work.
Three Bombadil properties remain opt-in: they require a Bombadil/browser scenario
and bounded fuzz campaign; no authoritative execution is claimed. The two model
smoke scripts require supplied model endpoints/models and remain explicit live
operations. No credentials or live-service authorization are implied by CI setup.
Firefox/WebKit alternatives remain opt-in and are not certified by Chromium CI.
+5 -1
View File
@@ -9,7 +9,11 @@
"test:photo-editor": "playwright test --config tests/e2e/playwright.config.js",
"test:photo-editor:install": "playwright install chromium firefox webkit",
"test:photo-editor:firefox": "PHOTO_EDITOR_E2E_BROWSER=firefox playwright test --config tests/e2e/playwright.config.js",
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js"
"test:photo-editor:webkit": "PHOTO_EDITOR_E2E_BROWSER=webkit playwright test --config tests/e2e/playwright.config.js",
"test:ios-layout": "playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:chromium": "ODYSSEUS_LAYOUT_BROWSER=chromium playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:webkit": "ODYSSEUS_LAYOUT_BROWSER=webkit playwright test --config tests/e2e/ios-layout.config.cjs",
"test:ios-layout:install": "playwright install chromium webkit"
},
"devDependencies": {
"@antithesishq/bombadil": "^0.7.0",
+160
View File
@@ -1030,6 +1030,166 @@ def _append_llama_cpp_linux_accel_build_lines(runner_lines: list[str]) -> None:
runner_lines.append(' fi # end _odysseus_have_prebuilt guard')
ODYSSEUS_LLAMA_SHIM_MARKER = "# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike"
def _append_llama_server_resolution_definition_lines(runner_lines: list[str]) -> None:
"""Define the helper shell function that discovers, reconciles and prioritizes llama-server."""
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
runner_lines.append('_odysseus_resolve_llama_server() {')
runner_lines.append(' _odysseus_llama_bin=""')
runner_lines.append(f' local _marker={marker}')
runner_lines.append(' local _saved_ifs="$IFS"')
runner_lines.append(" IFS=':'")
runner_lines.append(' local -a _parts')
runner_lines.append(' read -r -a _parts <<< "$PATH"')
runner_lines.append(' IFS="$_saved_ifs"')
runner_lines.append(' local _dir _cand')
runner_lines.append(' for _dir in "${_parts[@]}"; do')
runner_lines.append(' [ -n "$_dir" ] || continue')
runner_lines.append(' _cand="$_dir/llama-server"')
runner_lines.append(' [ -x "$_cand" ] || continue')
runner_lines.append(' if [ -f "$_cand" ] && [ ! -L "$_cand" ] && grep -Fxq "$_marker" "$_cand" 2>/dev/null; then')
runner_lines.append(' continue')
runner_lines.append(' fi')
runner_lines.append(' if "$_cand" --version >/dev/null 2>&1; then')
runner_lines.append(' _odysseus_llama_bin="$_cand"')
runner_lines.append(' break')
runner_lines.append(' fi')
runner_lines.append(' done')
runner_lines.append(' if [ -f "$HOME/bin/llama-server" ] && [ ! -L "$HOME/bin/llama-server" ] && grep -Fxq "$_marker" "$HOME/bin/llama-server" 2>/dev/null; then')
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
runner_lines.append(' echo "[odysseus] Native llama-server detected — retiring Odysseus compatibility shim in ~/bin"')
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
runner_lines.append(' hash -r')
runner_lines.append(' elif python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' _odysseus_llama_bin="$HOME/bin/llama-server"')
runner_lines.append(' else')
runner_lines.append(' echo "[odysseus] Removing stale Odysseus llama-server shim (python3 -m llama_cpp.server unavailable)"')
runner_lines.append(' rm -f "$HOME/bin/llama-server"')
runner_lines.append(' hash -r')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' if [ -n "$_odysseus_llama_bin" ]; then')
runner_lines.append(' local _cand_dir')
runner_lines.append(' _cand_dir="$(dirname "$_odysseus_llama_bin")"')
runner_lines.append(' if [ "${PATH%%:*}" != "$_cand_dir" ]; then')
runner_lines.append(' export PATH="$_cand_dir:$PATH"')
runner_lines.append(' fi')
runner_lines.append(' hash -r')
runner_lines.append(' return 0')
runner_lines.append(' fi')
runner_lines.append(' return 1')
runner_lines.append('}')
def _append_llama_server_shim_definition_lines(runner_lines: list[str]) -> None:
"""Define the helper shell function that generates the Odysseus compatibility shim."""
_append_llama_server_resolution_definition_lines(runner_lines)
marker = f"'{_bash_squote(ODYSSEUS_LLAMA_SHIM_MARKER)}'"
runner_lines.append('_odysseus_create_llama_shim() {')
runner_lines.append(' local _target="$HOME/bin/llama-server"')
runner_lines.append(f' local _marker={marker}')
runner_lines.append(' if [ -e "$_target" ] || [ -L "$_target" ]; then')
runner_lines.append(' if [ -f "$_target" ] && [ ! -L "$_target" ] && grep -Fxq "$_marker" "$_target" 2>/dev/null; then')
runner_lines.append(' : # existing Odysseus shim — safe to replace')
runner_lines.append(' else')
runner_lines.append(' echo "[odysseus] WARNING: $_target already exists and is not an Odysseus-managed shim; leaving untouched." >&2')
runner_lines.append(' return 1')
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' mkdir -p "$HOME/bin" || return 1')
runner_lines.append(' local _tmp')
runner_lines.append(' _tmp="$(mktemp "$HOME/bin/.llama-server.odysseus.tmp.XXXXXX")" || {')
runner_lines.append(' echo "[odysseus] ERROR: unable to create secure temporary llama-server shim in $HOME/bin" >&2')
runner_lines.append(' return 1')
runner_lines.append(' }')
runner_lines.append(" if ! cat > \"$_tmp\" <<'_ODY_LLAMA_SHIM_EOF'")
runner_lines.append('#!/usr/bin/env bash')
runner_lines.append(ODYSSEUS_LLAMA_SHIM_MARKER)
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
runner_lines.append('ARGS=()')
runner_lines.append('while [ $# -gt 0 ]; do')
runner_lines.append(' case "$1" in')
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
runner_lines.append(' --no-warmup) shift ;; # native-only')
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
runner_lines.append(' esac')
runner_lines.append('done')
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
runner_lines.append(' then')
runner_lines.append(' rm -f -- "$_tmp"')
runner_lines.append(' return 1')
runner_lines.append(' fi')
runner_lines.append(' chmod +x "$_tmp" || { rm -f -- "$_tmp"; return 1; }')
runner_lines.append(' mv -f -- "$_tmp" "$_target" || { rm -f -- "$_tmp"; return 1; }')
runner_lines.append(' hash -r')
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
runner_lines.append('}')
def _append_llama_server_shim_reconciliation_lines(runner_lines: list[str]) -> None:
"""Reconcile an existing ~/bin/llama-server if it is owned by Odysseus Cookbook.
Invariants:
- Never touch an arbitrary user-owned executable or symlink.
- If ~/bin/llama-server is an Odysseus generated shim:
- If a native llama-server is present and healthy elsewhere on PATH, remove the shim
so the native binary is used.
- If python3 -m llama_cpp.server is unavailable, remove the stale shim
and hash -r so the fallback/build path is triggered.
- If no healthy native server exists and llama_cpp.server is working, preserve it.
- If a healthy native candidate exists on PATH (even later in PATH), prioritize it and
ensure the launched executable is the validated candidate.
"""
runner_lines.append('[ -r ~/.config/odysseus-llama-cpp-env ] && . ~/.config/odysseus-llama-cpp-env')
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
_append_llama_server_resolution_definition_lines(runner_lines)
runner_lines.append('fi')
runner_lines.append('_odysseus_resolve_llama_server || true')
def _append_llama_server_finalization_lines(runner_lines: list[str], platform_hint: str = "") -> None:
"""Reconcile post-install launcher availability and enforce fail-closed invariant.
Guarantees that if python3 -m llama_cpp.server is usable but the `llama-server`
command is unresolvable, the compatibility launcher is created. If no usable
launcher exists after all build/install attempts, sets ODYSSEUS_PREFLIGHT_EXIT=127.
"""
err_msg = (
f"ERROR: llama.cpp serving is not available on {platform_hint} after install attempts."
if platform_hint
else "ERROR: llama.cpp serving is not available after install/build attempts."
)
runner_lines.append('if ! declare -F _odysseus_resolve_llama_server >/dev/null 2>&1; then')
_append_llama_server_resolution_definition_lines(runner_lines)
runner_lines.append('fi')
runner_lines.append('if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' _odysseus_create_llama_shim')
runner_lines.append(' _odysseus_resolve_llama_server || true')
runner_lines.append('fi')
runner_lines.append('if [ -z "$_odysseus_llama_bin" ]; then')
runner_lines.append(f' echo "{err_msg}"')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append('fi')
def _llama_cpp_rebuild_cmd(update_source: bool = False) -> str:
"""Shell command that clears the Cookbook-managed llama.cpp build.
+14 -55
View File
@@ -57,9 +57,11 @@ from routes.cookbook_helpers import (
_pip_install_no_cache, _user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
_diagnose_serve_output, run_ssh_command_async,
_ollama_bind_from_cmd, _pip_install_fallback_chain, _pip_install_no_cache,
_user_shell_path_bootstrap, _venv_safe_local_pip_install_cmd,
_append_pip_install_runner_lines, _pip_install_command_without_break_system_packages,
_normalize_llama_cpp_python_cache_types,
_append_llama_server_shim_definition_lines,
_append_llama_server_shim_reconciliation_lines,
_append_llama_server_finalization_lines,
ModelDownloadRequest, ServeRequest,
)
@@ -2161,7 +2163,7 @@ def setup_cookbook_routes() -> APIRouter:
ps_lines.append('}')
elif "llama_cpp" in req.cmd or "llama-server" in req.cmd:
ps_lines.append('# Auto-install llama-cpp-python if missing')
ps_lines.append('try { python -c "import llama_cpp" 2>$null } catch {}')
ps_lines.append('try { python -c "import llama_cpp.server" 2>$null } catch {}')
ps_lines.append('if ($LASTEXITCODE -ne 0) {')
ps_lines.append(' Write-Host "Installing llama-cpp-python..."')
ps_lines.append(' python -m pip install llama-cpp-python[server]')
@@ -2247,14 +2249,17 @@ def setup_cookbook_routes() -> APIRouter:
# ollama is found (otherwise macOS falls back to a slow source build).
# /opt/homebrew = Apple Silicon, /usr/local = Intel; harmless on Linux.
runner_lines.append('export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"')
_append_llama_server_shim_definition_lines(runner_lines)
_append_llama_server_shim_reconciliation_lines(runner_lines)
runner_lines.append('if [ -d /data/data/com.termux ]; then')
runner_lines.append(' # Termux: no native build — use the Python bindings (CPU).')
runner_lines.append(' if ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' if ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' pkg install -y cmake 2>/dev/null')
runner_lines.append(' pip install numpy diskcache jinja2 2>/dev/null')
runner_lines.append(' CMAKE_ARGS="-DGGML_BLAS=OFF -DGGML_LLAMAFILE=OFF" pip install \'llama-cpp-python[server]\' --no-build-isolation --no-cache-dir 2>&1 || true')
runner_lines.append(' fi')
runner_lines.append('elif ! command -v llama-server &>/dev/null; then')
_append_llama_server_finalization_lines(runner_lines, platform_hint="Termux")
runner_lines.append('elif [ -z "$_odysseus_llama_bin" ]; then')
runner_lines.append(' echo "Native llama-server not found — building from source (one-time, may take a few minutes)..."')
runner_lines.append(' mkdir -p ~/bin')
runner_lines.append(' cd ~ && [ -d llama.cpp ] || git clone --depth 1 https://github.com/ggml-org/llama.cpp')
@@ -2271,8 +2276,7 @@ def setup_cookbook_routes() -> APIRouter:
# would reuse the bad settings and fail again. CMAKE_BUILD_TYPE is
# explicit so the binary is optimized (Metal auto-enables on macOS).
runner_lines.append(' cd ~/llama.cpp && rm -rf build && cmake -B build -DCMAKE_BUILD_TYPE=Release \\')
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server \\')
runner_lines.append(' && ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server')
runner_lines.append(' && cmake --build build -j"$NPROC" --target llama-server')
runner_lines.append(' else')
_append_llama_cpp_linux_accel_build_lines(runner_lines)
runner_lines.append(' fi')
@@ -2296,58 +2300,13 @@ def setup_cookbook_routes() -> APIRouter:
runner_lines.append(' fi')
runner_lines.append(' fi')
runner_lines.append(' fi')
# SHORT-CIRCUIT before the build/pip fallback: if the
# native binary is missing but llama_cpp Python is already
# installed, drop a wrapper at ~/bin/llama-server that
# translates llama-server CLI args to llama_cpp.server's
# underscore-style flags. The user's serve command stays
# `llama-server ...` and "just works" — no build, no cmake,
# no second install. This is the path that unblocks every
# remote where pip-installed llama-cpp-python is already
# working but Cookbook used to insist on a native binary.
runner_lines.append(' if ! command -v llama-server >/dev/null 2>&1 && python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' mkdir -p ~/bin')
runner_lines.append(' cat > ~/bin/llama-server <<\'_ODY_LLAMA_SHIM_EOF\'')
runner_lines.append('#!/usr/bin/env bash')
runner_lines.append('# Auto-generated by Odysseus Cookbook: a `llama-server` lookalike')
runner_lines.append('# that translates the native CLI to `python -m llama_cpp.server`.')
runner_lines.append('# Lets cookbook-generated launch commands run unchanged on hosts')
runner_lines.append('# where only the pip llama-cpp-python package is installed.')
runner_lines.append('ARGS=()')
runner_lines.append('while [ $# -gt 0 ]; do')
runner_lines.append(' case "$1" in')
runner_lines.append(' -ngl|--gpu-layers|--n-gpu-layers) ARGS+=(--n_gpu_layers "$2"); shift 2 ;;')
runner_lines.append(' -c|--ctx-size) ARGS+=(--n_ctx "$2"); shift 2 ;;')
runner_lines.append(' -b|--batch-size) ARGS+=(--n_batch "$2"); shift 2 ;;')
runner_lines.append(' -ub|--ubatch-size) shift 2 ;; # llama-cpp-python has no separate ubatch')
runner_lines.append(' --flash-attn) ARGS+=(--flash_attn true); shift 2 ;;')
runner_lines.append(' --cache-type-k) ARGS+=(--type_k "$2"); shift 2 ;;')
runner_lines.append(' --cache-type-v) ARGS+=(--type_v "$2"); shift 2 ;;')
runner_lines.append(' --n-cpu-moe) ARGS+=(--n_cpu_moe "$2"); shift 2 ;;')
runner_lines.append(' --mmproj) ARGS+=(--clip_model_path "$2"); shift 2 ;;')
runner_lines.append(' --image-max-tokens) shift 2 ;; # native-only')
runner_lines.append(' --no-mmap) ARGS+=(--no_mmap true); shift ;;')
runner_lines.append(' --no-warmup) shift ;; # native-only')
runner_lines.append(' --chat-template) ARGS+=(--chat_format "$2"); shift 2 ;;')
runner_lines.append(' --fit|--split-mode|--tensor-split|--main-gpu|--parallel) shift 2 ;; # native-only')
runner_lines.append(' --mlock) ARGS+=(--use_mlock true); shift ;;')
runner_lines.append(' *) ARGS+=("$1"); shift ;;')
runner_lines.append(' esac')
runner_lines.append('done')
runner_lines.append('exec python3 -m llama_cpp.server "${ARGS[@]}"')
runner_lines.append('_ODY_LLAMA_SHIM_EOF')
runner_lines.append(' chmod +x ~/bin/llama-server')
runner_lines.append(' echo "[odysseus] Created llama-server shim → python -m llama_cpp.server (no native binary needed)"')
runner_lines.append(' fi')
runner_lines.append(' # If the native build failed, fall back to the Python bindings.')
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' _odysseus_resolve_llama_server || true')
# If the native build failed, fall back to the Python bindings.
runner_lines.append(' if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then')
runner_lines.append(' echo "llama-server build failed — installing Python bindings as fallback..."')
runner_lines.append(f" {_pip_install_fallback_chain('llama-cpp-python[server]', python_cmd='pip')} || true")
runner_lines.append(' fi')
runner_lines.append(' if ! command -v llama-server &>/dev/null && ! python3 -c "import llama_cpp" 2>/dev/null; then')
runner_lines.append(' echo "ERROR: llama.cpp serving is not available after install/build attempts."')
runner_lines.append(' ODYSSEUS_PREFLIGHT_EXIT=127')
runner_lines.append(' fi')
_append_llama_server_finalization_lines(runner_lines)
runner_lines.append('fi')
elif re.search(r"\bollama\s+serve\b", req.cmd):
handled_ollama_serve = True
+6 -36
View File
@@ -11,38 +11,6 @@ import time
from urllib.request import urlopen
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
def _stop_owned_server(server, identity):
"""Close only identity-bound descendants, including MCP's new sessions.
Capture before signalling the parent: an orphaned stderr holder can no
longer be attributed by ancestry. Never search by executable name or kill
the caller's process group.
"""
from src import process_lifecycle as lifecycle
descendants = (lifecycle.bind_descendants([identity.pid])
if identity.verdict() == lifecycle.OWNED else [])
if os.name == "nt":
if identity.verdict() == lifecycle.OWNED:
lifecycle.taskkill_tree(identity.pid)
else:
outcome = lifecycle.terminate_identities(
[*(entry.identity for entry in reversed(descendants)), identity],
steps=lifecycle.term_kill_steps(10),
)
if outcome.survivors or outcome.unverified:
raise RuntimeError(f"Disposable Notes cleanup could not verify exit: {outcome}")
try:
logs, _ = server.communicate(timeout=2)
except subprocess.TimeoutExpired as error:
# Do not turn an unidentified pipe holder into an unbounded wait.
server.stdout.close()
raise RuntimeError("Disposable Notes output pipe remained open after owned cleanup") from error
return logs
def main():
@@ -68,10 +36,7 @@ def main():
server = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "app:app", "--host", "127.0.0.1", "--port", str(port)],
cwd=target, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
start_new_session=(os.name != "nt"),
)
from src.process_lifecycle import ProcessIdentity
server_identity = ProcessIdentity.capture(server.pid)
result = None
try:
origin = f"http://127.0.0.1:{port}"
@@ -97,7 +62,12 @@ def main():
print(result.stderr, file=sys.stderr)
return result.returncode
finally:
logs = _stop_owned_server(server, server_identity)
server.terminate()
try:
logs, _ = server.communicate(timeout=10)
except subprocess.TimeoutExpired:
server.kill()
logs, _ = server.communicate()
if result is None or result.returncode:
print(logs[-8000:], file=sys.stderr)
+15 -5
View File
@@ -10,6 +10,7 @@ import logging
import os
import re
import asyncio
import contextlib
from typing import Any, Dict, List, Optional, Set, Tuple
from src.database import McpServer, SessionLocal
@@ -383,12 +384,21 @@ class McpManager:
provider = build_provider(server_id, url, on_redirect=_on_redirect)
stack = AsyncExitStack()
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
read_stream, write_stream, _get_session_id = transport
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
await session.initialize()
try:
transport = await stack.enter_async_context(streamablehttp_client(url, auth=provider))
read_stream, write_stream, _get_session_id = transport
session = await stack.enter_async_context(ClientSession(read_stream, write_stream))
await session.initialize()
tools_result = await session.list_tools()
tools_result = await session.list_tools()
except BaseException:
# Close the half-open transport here, in the task that entered
# it. Left for the garbage collector, its AnyIO cancel scope is
# exited from the wrong task and the event loop keeps spinning
# (#5518). Its own teardown error would only mask the real one.
with contextlib.suppress(Exception):
await stack.aclose()
raise
tools = []
for tool in tools_result.tools:
tools.append({
+6 -3
View File
@@ -2690,11 +2690,14 @@ body.theme-frosted .modal {
max-height: 95dvh !important;
}
/* Notes and Documents must respect the same viewport and safe-area
top as other mobile full-height sheets. Keep these authoritative rules
consistent with 05-documents.css and 14-email.css. */
body.doc-view .doc-editor-pane,
body.notes-view .notes-pane {
inset: auto 0 0 !important;
height: 95dvh !important;
max-height: 95dvh !important;
inset: var(--sheet-top) 0 0 !important;
height: calc(var(--app-height) - var(--sheet-top)) !important;
max-height: calc(var(--app-height) - var(--sheet-top)) !important;
}
}
-7
View File
@@ -16,13 +16,6 @@
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<script nonce="{{CSP_NONCE}}">
window._odysseusLoadTime = Date.now();
// Stops iOS focus-zoom on every field; iOS still allows pinch-zoom, other platforms would not.
(function(){
var ua = navigator.userAgent || '';
var isIOS = /iPad|iPhone|iPod/.test(ua) || (/Macintosh/.test(ua) && navigator.maxTouchPoints > 1);
var vp = document.querySelector('meta[name="viewport"]');
if (isIOS && vp) vp.setAttribute('content', vp.getAttribute('content') + ', maximum-scale=1');
})();
(function(){
try {
var t = JSON.parse(localStorage.getItem('odysseus-theme'));
-2
View File
@@ -379,7 +379,6 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
root.classList.toggle('keyboard-open', keyboardOpen);
if (keyboardOpen) root.style.setProperty('--keyboard-viewport-height', vv.height + 'px');
else root.style.removeProperty('--keyboard-viewport-height');
if (window.scrollY) window.scrollTo(0, 0);
};
// iOS fires visualViewport resize only once, mid-animation, and no focusin when
// the tapped field already had focus (e.g. the composer after a new chat)
@@ -392,7 +391,6 @@ document.addEventListener('DOMContentLoaded', markComposerUserEdited, { once: tr
window.visualViewport.addEventListener('scroll', _syncKeyboardViewport);
document.addEventListener('focusin', _syncThroughKeyboardAnimation);
document.addEventListener('focusout', _syncThroughKeyboardAnimation);
window.addEventListener('scroll', () => { if (window.scrollY) window.scrollTo(0, 0); }, { passive: true });
let _lastVVHeight = window.visualViewport.height;
window.visualViewport.addEventListener('resize', function() {
+3 -2
View File
@@ -267,8 +267,9 @@ export function makeWindowResizable(content, options = {}) {
content.style.maxWidth = 'none';
content.style.maxHeight = 'none';
const left = parseFloat(content.style.left);
if (Number.isFinite(left) && left + w > window.innerWidth - padRight) {
content.style.left = Math.max(padLeft, window.innerWidth - padRight - w) + 'px';
if (Number.isFinite(left)) {
const rightmostLeft = Math.max(padLeft, window.innerWidth - padRight - w);
content.style.left = Math.max(padLeft, Math.min(left, rightmostLeft)) + 'px';
}
}
} catch (_) {}
+52
View File
@@ -1,5 +1,7 @@
# Browser End-to-End Tests
## Photo Editor Release Gate
The photo editor release gate launches an isolated Odysseus server on port
`7013` with a temporary SQLite database and disposable authenticated account.
@@ -18,3 +20,53 @@ The disposable account is created in the temporary data directory and exercises
owner-scoped draft and notification routes without using an existing account.
`PHOTO_EDITOR_E2E_AUTH=0` selects the legacy unauthenticated configuration for
investigating it; offline-upgrade coverage requires the authenticated mode.
## Mobile and iOS Layout Suite
The dedicated mobile and iOS layout suite exercises viewport behavior,
accessibility zoom, scrolling, mobile safe-area geometry (Notes and Documents
panes), and floating window restoration against an isolated local Odysseus server
running on port `17739`.
```bash
npm install
npm run test:ios-layout:chromium
```
### Profiles and Browser Prerequisites
- **Chromium profiles** (`desktop-chromium`, `phone-chromium-portrait`, `phone-chromium-landscape`, `tablet-chromium`):
Provide automated baseline coverage in CI and local development without requiring
WebKit dependencies.
- **WebKit profiles** (`phone-webkit-portrait`, `phone-webkit-landscape`, `tablet-webkit`):
Used for platform-oriented WebKit rendering validation. WebKit requires browser binaries:
```bash
npm run test:ios-layout:install
# or: npx playwright install webkit
npm run test:ios-layout:webkit
```
To run all installed profiles:
```bash
npm run test:ios-layout
```
### Evidence and Cleanup
- **Evidence artifacts**: Screenshots are written to `test-results/ios-layout/` by
default and traces are written to `test-results/ios-layout-traces/`. Both paths
are git-ignored. Set `ODYSSEUS_LAYOUT_EVIDENCE_DIR` to override the screenshot
output path.
- **Teardown**: Disposable application state and database files created in the
OS temporary directory are automatically cleaned up on both normal and failure
teardown, preserving diagnostic screenshots and failure traces.
### Platform Considerations
Emulated Chromium and WebKit viewports validate deterministic CSS cascade,
computed layout geometry, viewport meta tags, and JavaScript restoration. However,
browser emulation is not a physical device substitute: physical iOS Safari and
standalone installed PWAs should still be verified separately for native gesture
pinch-zoom, software keyboard dismissal transitions, and dynamic hardware notch/island insets.
-19
View File
@@ -1,19 +0,0 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const marker = '.disposable-ci-fixture.json';
function disposableCIArtifacts() {
if (!(process.env.CI || process.env.GITHUB_ACTIONS) || process.env.ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS !== '1') return false;
const directory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
if (!directory || process.env.PHOTO_EDITOR_E2E_AUTH === '0') return false;
const resolved = path.resolve(directory);
if (!path.basename(resolved).startsWith('odysseus-photo-editor-e2e-')) return false;
if (![os.tmpdir(), '/tmp', '/var/tmp'].some(base => resolved.startsWith(path.resolve(base) + path.sep))) return false;
if (process.env.PHOTO_EDITOR_E2E_DB_PATH !== path.join(resolved, 'app.db')) return false;
try {
const fixture = JSON.parse(fs.readFileSync(path.join(resolved, marker), 'utf8'));
return fixture.synthetic === true && fixture.authenticated === true && fixture.database === path.join(resolved, 'app.db');
} catch { return false; }
}
module.exports = { disposableCIArtifacts, marker };
+28
View File
@@ -0,0 +1,28 @@
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
/**
* Global teardown for the iOS/mobile layout Playwright test suite.
* Cleans up only disposable temporary application data directories,
* preserving diagnostic artifacts (screenshots and failure traces) in test-results/.
*/
module.exports = async () => {
const dataDirectory = process.env.ODYSSEUS_LAYOUT_DATA_DIR;
if (!dataDirectory || typeof dataDirectory !== 'string') return;
try {
const resolvedData = path.resolve(dataDirectory);
const allowedTmpBases = [os.tmpdir(), '/var/tmp', '/tmp'].map(b => path.resolve(b));
const isTmp = allowedTmpBases.some(base => resolvedData.startsWith(base + path.sep));
const baseName = path.basename(resolvedData);
if (isTmp && (baseName.startsWith('odysseus-ios-layout-') || baseName.startsWith('odysseus-pr6396-'))) {
if (fs.existsSync(resolvedData)) {
fs.rmSync(resolvedData, { recursive: true, force: true });
}
}
} catch (err) {
console.warn(`[ios-layout-teardown] Warning: failed to clean temporary runtime dir: ${err.message}`);
}
};
+148
View File
@@ -0,0 +1,148 @@
// Tests against the running Odysseus app, not a static mock page.
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { defineConfig } = require('@playwright/test');
const repositoryRoot = path.resolve(__dirname, '../..');
const port = Number(process.env.ODYSSEUS_LAYOUT_PORT || '17739');
const baseURL = `http://127.0.0.1:${port}`;
const defaultPython = process.platform === 'win32'
? path.join(repositoryRoot, '.venv', 'Scripts', 'python.exe')
: path.join(repositoryRoot, '.venv', 'bin', 'python');
const python = process.env.ODYSSEUS_TEST_PYTHON
|| (fs.existsSync(defaultPython) ? defaultPython : 'python3');
const dataDirectory = process.env.ODYSSEUS_LAYOUT_DATA_DIR
|| fs.mkdtempSync(path.join(os.tmpdir(), 'odysseus-ios-layout-'));
process.env.ODYSSEUS_LAYOUT_DATA_DIR = dataDirectory;
// Default evidence directory to repository-relative test-results/ios-layout
// while preserving the environment-variable override if provided.
const evidenceDir = process.env.ODYSSEUS_LAYOUT_EVIDENCE_DIR
|| path.join(repositoryRoot, 'test-results', 'ios-layout');
const outputDir = path.join(repositoryRoot, 'test-results', 'ios-layout-traces');
// Emergency process-exit cleanup for disposable application runtime state
const cleanDataDirSync = () => {
const dir = process.env.ODYSSEUS_LAYOUT_DATA_DIR;
if (!dir || typeof dir !== 'string') return;
try {
const resolved = path.resolve(dir);
const allowed = [os.tmpdir(), '/var/tmp', '/tmp'].map(b => path.resolve(b));
if (allowed.some(base => resolved.startsWith(base + path.sep))) {
const baseName = path.basename(resolved);
if (baseName.startsWith('odysseus-ios-layout-') || baseName.startsWith('odysseus-pr6396-')) {
if (fs.existsSync(resolved)) {
fs.rmSync(resolved, { recursive: true, force: true });
}
}
}
} catch (_) {}
};
process.once('SIGINT', () => { cleanDataDirSync(); process.exit(130); });
process.once('SIGTERM', () => { cleanDataDirSync(); process.exit(143); });
function isWebKitInstalled() {
try {
const { webkit } = require('@playwright/test');
return fs.existsSync(webkit.executablePath());
} catch (_) {
return false;
}
}
const chromiumProjects = [
{
name: 'desktop-chromium',
use: { browserName: 'chromium', viewport: { width: 1440, height: 900 } },
},
{
name: 'phone-chromium-portrait',
use: { browserName: 'chromium', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true },
},
{
name: 'phone-chromium-landscape',
use: { browserName: 'chromium', viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true },
},
{
name: 'tablet-chromium',
use: { browserName: 'chromium', viewport: { width: 820, height: 1000 }, hasTouch: true, isMobile: true },
},
];
const webkitProjects = [
{
name: 'phone-webkit-portrait',
use: { browserName: 'webkit', viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: true },
},
{
name: 'phone-webkit-landscape',
use: { browserName: 'webkit', viewport: { width: 844, height: 390 }, hasTouch: true, isMobile: true },
},
{
name: 'tablet-webkit',
use: { browserName: 'webkit', viewport: { width: 820, height: 1000 }, hasTouch: true, isMobile: true },
},
];
// Determine active projects based on environment and local browser availability
const requestedBrowser = (process.env.ODYSSEUS_LAYOUT_BROWSER || 'all').toLowerCase();
let selectedProjects = [];
if (requestedBrowser === 'chromium') {
selectedProjects = chromiumProjects;
} else if (requestedBrowser === 'webkit') {
selectedProjects = webkitProjects;
} else {
// 'all': Include chromium always; include webkit if installed or if explicitly targeted via cli args
const hasCliWebKitTarget = process.argv.some(arg => arg.toLowerCase().includes('webkit'));
if (isWebKitInstalled() || hasCliWebKitTarget) {
selectedProjects = [...chromiumProjects, ...webkitProjects];
} else {
// Only Chromium is available locally (e.g. standard CI / default runner)
selectedProjects = chromiumProjects;
}
}
module.exports = defineConfig({
testDir: path.join(__dirname, 'ios-layout'),
testMatch: ['**/*.spec.cjs'],
outputDir,
timeout: 90_000,
expect: { timeout: 15_000 },
retries: 0,
workers: 1,
reporter: [['line']],
globalTeardown: require.resolve('./ios-layout-teardown.cjs'),
use: {
baseURL,
headless: true,
serviceWorkers: 'block',
screenshot: 'only-on-failure',
trace: 'retain-on-failure',
},
projects: selectedProjects,
metadata: { evidenceDir },
webServer: {
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
cwd: repositoryRoot,
url: baseURL,
timeout: 120_000,
reuseExistingServer: false,
env: {
...process.env,
AUTH_ENABLED: 'false',
// Never connect the disposable app to the host's real ChromaDB.
CHROMADB_HOST: '127.0.0.1',
CHROMADB_PORT: String(port + 1),
LLM_HOST: '127.0.0.1',
EMBEDDING_URL: `http://127.0.0.1:${port + 1}/v1/embeddings`,
DATABASE_URL: `sqlite:///${path.join(dataDirectory, 'app.db')}`,
ODYSSEUS_DATA_DIR: dataDirectory,
ODYSSEUS_STARTUP_WARMUPS: '0',
RESPONSE_COMPRESSION_ENABLED: 'false',
},
},
});
@@ -0,0 +1,313 @@
const { test, expect } = require('@playwright/test');
const fs = require('node:fs');
const path = require('node:path');
async function openApp(page) {
const response = await page.goto('/', { waitUntil: 'domcontentloaded' });
expect(response?.ok()).toBeTruthy();
// The runtime currently produces two #message nodes. Select the
// initialized composer while diagnosing whether both are visible.
const composers = await page.locator('textarea#message').evaluateAll(nodes =>
nodes.map((el, index) => ({
index,
initialized: el.dataset.startupPreserveBound === '1',
visible: !!el.getClientRects().length &&
getComputedStyle(el).visibility !== 'hidden',
parent: el.parentElement?.className || '',
}))
);
if (composers.length !== 1) {
console.log('Composer DOM diagnostic:', JSON.stringify(composers));
}
const activeComposer = page.locator(
'textarea#message[data-startup-preserve-bound="1"]'
);
await expect(activeComposer).toHaveCount(1);
await expect(activeComposer).toBeVisible();
expect(
composers.filter(composer => composer.visible).length,
'Exactly one composer should be visible'
).toBe(1);
}
function isMobileViewport(testInfo) {
const width = testInfo.project.use?.viewport?.width || 0;
return width > 0 && width <= 768;
}
/**
* Reach Settings through genuine UI controls on either desktop or mobile viewports.
* On mobile (<=768px), the rail is hidden and the sidebar must be opened via hamburger.
*/
async function openSettings(page, isMobile) {
let settingsButton;
if (isMobile) {
const hamburger = page.locator('#hamburger-btn:visible').first();
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
await hamburger.click();
const sidebar = page.locator('#sidebar:not(.hidden)');
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
await page.waitForTimeout(300);
settingsButton = sidebar.locator('#user-bar-settings');
} else {
settingsButton = page.locator('#rail-settings:visible, #user-bar-settings:visible').first();
}
await expect(settingsButton, 'Settings button must be reachable and visible').toBeVisible({ timeout: 5000 });
await settingsButton.click();
const dialog = page.locator('#settings-modal');
await expect(dialog, 'Settings modal must become visible').toBeVisible({ timeout: 5000 });
return dialog;
}
/**
* Reach Notes through genuine UI controls on either desktop or mobile viewports.
*/
async function openNotes(page, isMobile) {
let notesButton;
if (isMobile) {
const hamburger = page.locator('#hamburger-btn:visible').first();
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
await hamburger.click();
const sidebar = page.locator('#sidebar:not(.hidden)');
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
await page.waitForTimeout(300);
notesButton = sidebar.locator('#tool-notes-btn');
} else {
notesButton = page.locator('#rail-notes:visible, #tool-notes-btn:visible').first();
}
await expect(notesButton, 'Notes button must be reachable and visible').toBeVisible({ timeout: 5000 });
await notesButton.click();
const realNotes = page.locator('.notes-pane').first();
await expect(realNotes, 'Notes pane must become visible').toBeVisible({ timeout: 5000 });
return realNotes;
}
/**
* Reach Document editor through genuine UI controls on either desktop or mobile viewports.
*/
async function openDocument(page, isMobile) {
let docButton;
if (isMobile) {
const hamburger = page.locator('#hamburger-btn:visible').first();
await expect(hamburger, 'Hamburger button must be visible on mobile').toBeVisible({ timeout: 5000 });
await hamburger.click();
const sidebar = page.locator('#sidebar:not(.hidden)');
await expect(sidebar, 'Sidebar must open upon hamburger tap').toBeVisible({ timeout: 5000 });
await page.waitForTimeout(300);
docButton = sidebar.locator('#library-new-doc-btn');
} else {
docButton = page.locator('#library-new-doc-btn:visible').first();
if (!(await docButton.count())) {
const hamburger = page.locator('#hamburger-btn:visible').first();
if (await hamburger.count()) {
await hamburger.click();
await page.waitForTimeout(300);
docButton = page.locator('#sidebar:not(.hidden) #library-new-doc-btn');
}
}
}
await expect(docButton, 'Document creation button must be reachable and visible').toBeVisible({ timeout: 5000 });
await docButton.click();
const docPane = page.locator('.doc-editor-pane').first();
await expect(docPane, 'Document editor pane must become visible').toBeVisible({ timeout: 10000 });
return docPane;
}
test('renders actual Odysseus UI and captures review screenshots', async ({ page }, testInfo) => {
await openApp(page);
const out = testInfo.config.metadata.evidenceDir;
fs.mkdirSync(out, { recursive: true });
const name = testInfo.project.name;
const isMobile = isMobileViewport(testInfo);
// 1. Chat screenshot (default view)
await page.screenshot({ path: path.join(out, `${name}-chat.png`), fullPage: false, animations: 'disabled' });
// 2. Settings screenshot via actual user-visible navigation
await openApp(page);
await openSettings(page, isMobile);
await page.screenshot({ path: path.join(out, `${name}-settings.png`), fullPage: false, animations: 'disabled' });
// 3. Notes screenshot via actual user-visible navigation
await openApp(page);
await openNotes(page, isMobile);
await page.screenshot({ path: path.join(out, `${name}-notes.png`), fullPage: false, animations: 'disabled' });
testInfo.annotations.push({ type: 'evidence', description: out });
});
test('viewport meta does not restrict user zoom', async ({ page }) => {
await openApp(page);
const meta = await page.locator('meta[name="viewport"]').getAttribute('content');
expect(meta || '').not.toMatch(/maximum-scale\s*=\s*1(?:\b|\.)/i);
expect(meta || '').not.toMatch(/user-scalable\s*=\s*no/i);
});
test('browser-owned window scrolling is not globally reset', async ({ page }) => {
await openApp(page);
const value = await page.evaluate(async () => {
document.documentElement.style.overflowY = 'auto';
document.body.style.overflowY = 'auto';
document.body.style.height = 'auto';
document.body.style.minHeight = '3200px';
const spacer = document.createElement('div');
spacer.style.cssText = 'position:absolute;top:3100px;width:1px;height:10px;pointer-events:none;';
document.body.appendChild(spacer);
window.scrollTo(0, 250);
await new Promise(resolve => setTimeout(resolve, 150));
return window.scrollY;
});
expect(value).toBeGreaterThan(100);
});
test('CSS cascade rules for mobile Notes and Documents respect safe areas', async ({ page }, testInfo) => {
test.skip(!isMobileViewport(testInfo), 'Only mobile viewports <=768px');
await openApp(page);
const results = await page.evaluate(() => {
document.documentElement.style.setProperty('--safe-top', '44px');
document.documentElement.style.setProperty('--sheet-top', '68px');
document.documentElement.style.setProperty('--app-height', '844px');
const values = {};
for (const [key, bodyClass, paneClass] of [
['notes', 'notes-view', 'notes-pane'],
['document', 'doc-view', 'doc-editor-pane'],
]) {
const pane = document.createElement('section');
pane.className = paneClass;
pane.style.opacity = '0'; // CSS cascade regression check
document.body.appendChild(pane);
document.body.classList.add(bodyClass);
const style = window.getComputedStyle(pane);
values[key] = {
top: style.top,
height: style.height,
insetTop: pane.getBoundingClientRect().top,
};
pane.remove();
document.body.classList.remove(bodyClass);
}
return values;
});
for (const key of ['notes', 'document']) {
expect(results[key].top, key).toBe('68px');
expect(parseFloat(results[key].height), key).toBeGreaterThanOrEqual(770);
}
});
test('real Notes and Document panes honor safe-area geometry in the live application', async ({ page }, testInfo) => {
const isMobile = isMobileViewport(testInfo);
test.skip(!isMobile, 'Only mobile viewports <=768px');
await openApp(page);
// Configure simulated safe-area insets (e.g. iPhone notch / Dynamic Island)
await page.evaluate(() => {
document.documentElement.style.setProperty('--safe-top', '44px');
document.documentElement.style.setProperty('--sheet-top', '68px');
document.documentElement.style.setProperty('--app-height', '844px');
});
// 1. Verify actual Notes UI
const realNotes = await openNotes(page, isMobile);
const notesMetrics = await realNotes.evaluate(el => {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return {
top: style.top,
height: style.height,
maxHeight: style.maxHeight,
rectTop: rect.top,
rectHeight: rect.height,
};
});
expect(notesMetrics.top, 'Real Notes pane top must match --sheet-top').toBe('68px');
expect(parseFloat(notesMetrics.height), 'Real Notes pane height must respect safe area').toBeGreaterThanOrEqual(770);
expect(notesMetrics.rectTop, 'Real Notes pane must not overlap the 44px safe-area top notch').toBeGreaterThanOrEqual(44);
// 2. Verify actual Document UI
await openApp(page);
await page.evaluate(() => {
document.documentElement.style.setProperty('--safe-top', '44px');
document.documentElement.style.setProperty('--sheet-top', '68px');
document.documentElement.style.setProperty('--app-height', '844px');
});
const realDoc = await openDocument(page, isMobile);
const docMetrics = await realDoc.evaluate(el => {
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return {
top: style.top,
height: style.height,
maxHeight: style.maxHeight,
rectTop: rect.top,
rectHeight: rect.height,
};
});
expect(docMetrics.top, 'Real Document pane top must match --sheet-top').toBe('68px');
expect(parseFloat(docMetrics.height), 'Real Document pane height must respect safe area').toBeGreaterThanOrEqual(770);
expect(docMetrics.rectTop, 'Real Document pane must not overlap the 44px safe-area top notch').toBeGreaterThanOrEqual(44);
});
test('restored floating windows clamp away from a left safe area', async ({ page }, testInfo) => {
test.skip(isMobileViewport(testInfo), 'Geometry test uses desktop resizable windows');
await openApp(page);
const value = await page.evaluate(async () => {
document.documentElement.style.setProperty('--safe-left', '58px');
document.body.style.setProperty('padding-left', '58px', 'important');
const probe = document.createElement('div');
probe.style.cssText = 'position:fixed;left:10px;top:80px;width:420px;height:250px;z-index:999999;';
document.body.appendChild(probe);
const key = 'odysseus-layout-restore-test';
localStorage.setItem(key, JSON.stringify({ w: 480, h: 280 }));
try {
// Body horizontal padding transitions over 160ms. Wait for the
// simulated safe-area change to settle before restoring geometry.
await new Promise(resolve => setTimeout(resolve, 300));
const padAtRestore = parseFloat(
getComputedStyle(document.body).paddingLeft
);
if (Math.abs(padAtRestore - 58) > 0.5) {
throw new Error(
`Safe-area fixture did not settle: ${padAtRestore}px`
);
}
// Verify the running server actually serves the patched module.
const response = await fetch(
'/static/js/windowResize.js',
{ cache: 'no-store' }
);
if (!response.ok) {
throw new Error('Cannot fetch windowResize.js');
}
const moduleSource = await response.text();
if (!moduleSource.includes('const rightmostLeft = Math.max(padLeft')) {
throw new Error('Running server is serving unpatched windowResize.js');
}
const { makeWindowResizable } = await import('/static/js/windowResize.js');
makeWindowResizable(probe, { storageKey: key, mobileSkip: 768 });
await new Promise(resolve => setTimeout(resolve, 300));
return {
left: parseFloat(probe.style.left),
width: parseFloat(probe.style.width),
paddingLeft: parseFloat(getComputedStyle(document.body).paddingLeft),
viewportWidth: window.innerWidth,
};
} finally {
localStorage.removeItem(key);
probe.remove();
}
});
expect(value.paddingLeft).toBeGreaterThanOrEqual(58);
expect(value.width).toBe(480);
expect(value.left).toBeGreaterThanOrEqual(value.paddingLeft);
});
+2 -29
View File
@@ -35,7 +35,7 @@ test('brush presets and shared stroke controls remain reusable', async ({ page }
await expect(page.locator('#ge-brush-preset option', { hasText: 'My Detail Brush' })).toHaveCount(0);
});
test('long sampled strokes stay responsive and undo atomically', async ({ page, browserName }, testInfo) => {
test('long sampled strokes stay responsive and undo atomically', async ({ page }) => {
await openBlankEditor(page);
await selectTool(page, 'brush');
await page.locator('.ge-size-slider').fill('700');
@@ -49,40 +49,13 @@ test('long sampled strokes stay responsive and undo atomically', async ({ page,
const before = await flattenedPixelDigest(page);
const start = await canvasPoint(page, 0.08, 0.5);
const end = await canvasPoint(page, 0.92, 0.5);
// Playwright delivers stepped moves at frame cadence: 240 events already
// cost ~4s on an inert page. Measure that transport cost with the same input,
// and separately bound real Chromium main-thread work at the original5s.
const control = await page.context().newPage();
let controlDuration;
try {
await control.setContent('<canvas style="position:fixed;inset:0;width:100vw;height:100vh"></canvas>');
const started = Date.now();
await control.mouse.move(start.x, start.y);
await control.mouse.down();
await control.mouse.move(end.x, end.y, { steps: 240 });
await control.mouse.up();
controlDuration = Date.now() - started;
} finally {
await control.close();
await page.bringToFront();
}
const metrics = browserName === 'chromium' ? await page.context().newCDPSession(page) : null;
if (metrics) await metrics.send('Performance.enable');
const taskTime = async () => (await metrics.send('Performance.getMetrics')).metrics.find(item => item.name === 'TaskDuration').value *1000;
const beforeTask = metrics ? await taskTime() : null;
const startedAt = Date.now();
await page.mouse.move(start.x, start.y);
await page.mouse.down();
await page.mouse.move(end.x, end.y, { steps: 240 });
await page.mouse.up();
const strokeDuration = Date.now() - startedAt;
const mainThreadDuration = metrics ? (await taskTime()) - beforeTask : null;
if (metrics) await metrics.detach();
await testInfo.attach('stroke-timing', { body: Buffer.from(JSON.stringify({
samples: 240, strokeDuration, controlDuration, mainThreadDuration,
})), contentType: 'application/json' });
expect(strokeDuration - controlDuration).toBeLessThan(5000);
if (mainThreadDuration !== null) expect(mainThreadDuration).toBeLessThan(5000);
expect(strokeDuration).toBeLessThan(5000);
const after = await flattenedPixelDigest(page);
expect(after).not.toEqual(before);
await expect(thumb).toHaveAttribute('width', '68');
@@ -1,21 +0,0 @@
const { test, expect } = require('@playwright/test');
const { spawnSync } = require('node:child_process');
const path = require('node:path');
// The previously opt-in node:test now uses only this config's owned app/account.
// Its native JUnit report retains its authored identity; this case is a driver.
test('native email reply regression on the disposable authenticated app', async ({ context, baseURL }, testInfo) => {
const origin = new URL(baseURL);
expect(origin.hostname).toBe('127.0.0.1');
const cookie = (await context.cookies(baseURL)).find(item => item.name === 'odysseus_session');
expect(cookie?.value).toBeTruthy();
const report = testInfo.outputPath('native-email-junit.xml');
const result = spawnSync(process.execPath, ['--test', '--test-reporter=spec', '--test-reporter-destination=stdout',
'--test-reporter=junit', `--test-reporter-destination=${report}`, 'tests/emailReplyBrowser.test.mjs'], {
cwd: path.resolve(__dirname, '../../..'), encoding: 'utf8', timeout: 80_000,
env: { ...process.env, ODYSSEUS_UI_TEST_URL: baseURL, ODYSSEUS_UI_TEST_COOKIE: cookie.value },
});
console.log(result.stdout);
expect(result.error, String(result.error)).toBeUndefined();
expect(result.status, result.stdout + result.stderr).toBe(0);
});
+3 -17
View File
@@ -2,12 +2,6 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { defineConfig } = require('@playwright/test');
const { disposableCIArtifacts, marker } = require('./artifact-policy');
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const disposableOptIn = isCI && process.env.ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS === '1';
if (disposableOptIn && (process.env.PHOTO_EDITOR_E2E_DATA_DIR || process.env.PHOTO_EDITOR_E2E_DB_PATH || process.env.PHOTO_EDITOR_E2E_AUTH === '0') && !disposableCIArtifacts()) {
throw new Error('CI artifact opt-in requires fresh authenticated fixture paths');
}
const port = Number(process.env.PHOTO_EDITOR_E2E_PORT || 7013);
const baseURL = `http://127.0.0.1:${port}`;
@@ -30,9 +24,7 @@ if (!['chromium', 'firefox', 'webkit'].includes(browserName)) {
throw new Error(`Unsupported PHOTO_EDITOR_E2E_BROWSER: ${browserName}`);
}
if (disposableOptIn) {
fs.writeFileSync(path.join(dataDirectory, marker), JSON.stringify({ synthetic: true, authenticated: true, database: databasePath }));
}
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
module.exports = defineConfig({
@@ -40,8 +32,6 @@ module.exports = defineConfig({
outputDir: path.join(__dirname, '..', '..', 'test-results', 'photo-editor'),
timeout: 90_000,
expect: { timeout: 10_000 },
forbidOnly: isCI,
retries: 0,
fullyParallel: false,
workers: 1,
reporter: isCI ? 'line' : 'line',
@@ -59,9 +49,9 @@ module.exports = defineConfig({
serviceWorkers: 'block',
...(authenticated ? { storageState: path.join(dataDirectory, 'browser-auth.json') } : {}),
screenshot: 'only-on-failure',
// Default CI suppression remains; only the fresh synthetic fixture opts in.
// Unconditional CI trace suppression: Playwright traces are NEVER recorded in CI.
// Local developer tracing requires explicit PLAYWRIGHT_ENABLE_TRACE=1.
trace: (traceOptIn || disposableCIArtifacts()) ? 'retain-on-failure' : 'off',
trace: traceOptIn ? 'retain-on-failure' : 'off',
},
webServer: {
command: `${JSON.stringify(python)} -m uvicorn app:app --host 127.0.0.1 --port ${port}`,
@@ -75,10 +65,6 @@ module.exports = defineConfig({
DATABASE_URL: `sqlite:///${databasePath}`,
ODYSSEUS_DATA_DIR: dataDirectory,
ODYSSEUS_STARTUP_WARMUPS: '0',
ODYSSEUS_TOOL_INDEX_PREWARM: '0',
ODYSSEUS_BROWSER_MCP_REQUIRE_CACHE: '1',
npm_config_offline: 'true',
npm_config_cache: path.join(dataDirectory, 'npm-cache'),
RESPONSE_COMPRESSION_ENABLED: 'false',
},
},
+2 -7
View File
@@ -2,7 +2,6 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { execFileSync } = require('node:child_process');
const { disposableCIArtifacts } = require('./artifact-policy');
const repoRoot = path.resolve(__dirname, '..', '..');
const defaultResultsDir = path.join(repoRoot, 'test-results');
@@ -30,9 +29,6 @@ module.exports = async (options = {}) => {
const isPlaywrightConfig = options && (options.configFile || options.rootDir);
const testOpts = (!isPlaywrightConfig && typeof options === 'object') ? options : {};
// Validate fixture ownership before removing its temporary directory.
const disposableCI = disposableCIArtifacts();
// Confine cleanup of PHOTO_EDITOR_E2E_DATA_DIR:
// Strictly require it to be within an OS temporary directory and match the expected test prefix
const dataDirectory = process.env.PHOTO_EDITOR_E2E_DATA_DIR;
@@ -104,7 +100,7 @@ module.exports = async (options = {}) => {
const isCI = Boolean(process.env.CI) || Boolean(process.env.GITHUB_ACTIONS);
const traceOptIn = !isCI && ['1', 'true'].includes(String(process.env.PLAYWRIGHT_ENABLE_TRACE || '').toLowerCase());
if ((isCI && !disposableCI) || (!traceOptIn && !disposableCI)) {
if (isCI || !traceOptIn) {
// In CI or default local runs, purge all sensitive trace archives and artifacts
purgeSensitiveArtifacts(resultsDir);
} else {
@@ -128,8 +124,7 @@ module.exports = async (options = {}) => {
if (!sanitizerSucceeded) {
// Fail closed: purge all remaining zip or tmp archives
if (disposableCI) purgeSensitiveArtifacts(resultsDir);
else purgeZipAndTmp(resultsDir);
purgeZipAndTmp(resultsDir);
}
}
}
-71
View File
@@ -1,71 +0,0 @@
{
"native-ci": [
"tests/backgroundToolJobs.test.mjs",
"tests/chatEditorProgress.test.mjs",
"tests/chatImageDeletion.test.mjs",
"tests/chatPhotoEditorIntegrity.test.mjs",
"tests/chatProcessingHandoff.test.mjs",
"tests/copyChatHeader.test.mjs",
"tests/emailReplyStream.test.mjs",
"tests/galleryModelPickerSync.test.mjs",
"tests/generatedImageResult.test.mjs",
"tests/researchMobileButtons.test.mjs",
"tests/schemaThinkingProbe.test.mjs",
"tests/sidebarNewChat.test.mjs",
"tests/skillsApproval.test.mjs",
"tests/streamingRendererRaf.test.mjs",
"tests/toolFollowupOracle.test.mjs",
"tests/tool_followup_oracle.test.mjs"
],
"browser-ci": [
"tests/emailReplyBrowser.test.mjs"
],
"python-ci": {
"tests/historyResumeRendering.test.mjs": [
"tests/test_history_resume_rendering_js.py",
"tests/test_live_fallback_round_attribution.py"
],
"tests/live_thinking_scheduler.test.mjs": [
"tests/test_live_thinking_scheduler_js.py"
],
"tests/noteTestOracle.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesApplication.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesCharacterization.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesDraftAutosave.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesInfrastructure.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesRecurrence.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesReminders.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/notesWorkflows.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/selectedNotesContext.test.mjs": [
"tests/test_notes_reference_contracts_js.py"
],
"tests/streaming/invariant.test.mjs": [
"tests/test_streaming_segmenter_js.py"
],
"tests/streaming/segmenter.test.mjs": [
"tests/test_streaming_segmenter_js.py"
],
"tests/turnRendering.test.mjs": [
"tests/test_turn_rendering_js.py"
]
},
"architecture-ci": [
"tests/frontendArchitecture.test.mjs"
]
}
@@ -1,4 +1,14 @@
import os
import subprocess
from pathlib import Path
import pytest
from core.platform_compat import find_bash, git_bash_path
from routes.cookbook_helpers import (
ODYSSEUS_LLAMA_SHIM_MARKER,
_append_llama_server_shim_definition_lines,
_append_llama_server_shim_reconciliation_lines,
_append_llama_server_finalization_lines,
)
from tests.helpers.stylesheets import app_css
@@ -615,3 +625,730 @@ def test_local_dependency_probe_refreshes_user_site_visibility():
# deps (realesrgan) probe as not-installed until a restart. See #4810.
assert "if user_site and os.path.isdir(user_site):" in source
assert "site.addsitedir(user_site)" in source
def test_llama_runner_path_preserves_standard_user_bin_order():
"""The runner PATH must preserve standard user $HOME/bin precedence while
relying on ownership-aware reconciliation to retire Odysseus shims when
native builds exist."""
source = _read("routes/cookbook_routes.py")
assert (
'export PATH="$HOME/.local/bin:$HOME/bin:$HOME/llama.cpp/build/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"'
in source
)
def _run_shell(tmp_path, lines, cwd=None, path=None, extra_env=None):
from core.platform_compat import find_bash, git_bash_path
bash = find_bash() or "bash"
env = dict(os.environ, HOME=git_bash_path(tmp_path))
if path is not None:
env["PATH"] = path
if extra_env:
env.update(extra_env)
return subprocess.run(
[bash, "-c", "\n".join(lines)],
cwd=str(cwd) if cwd else None,
env=env,
capture_output=True,
text=True,
)
def _write_odysseus_shim(path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
f"#!/usr/bin/env bash\n{ODYSSEUS_LLAMA_SHIM_MARKER}\nexec python3 -m llama_cpp.server \"$@\"\n",
encoding="utf-8",
)
path.chmod(0o755)
def _write_mock_native(path: Path, succeeds: bool = True) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
if succeeds:
content = '#!/usr/bin/env bash\nif [ "$1" = "--version" ]; then echo "version: 0.1.0-dev"; exit 0; fi\necho "RUN_NATIVE: $@"\nexit 0\n'
else:
content = '#!/usr/bin/env bash\necho "error: missing libllama.so" >&2\nexit 127\n'
path.write_text(content, encoding="utf-8")
path.chmod(0o755)
def _write_mock_python3(path: Path, succeeds: bool = True) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(f"#!/usr/bin/env bash\nexit {0 if succeeds else 1}\n", encoding="utf-8")
path.chmod(0o755)
def test_odysseus_create_llama_shim_never_overwrites_user_regular_file(tmp_path):
"""Creation boundary: never overwrite a non-executable user-owned regular file."""
user_file = tmp_path / "bin/llama-server"
user_file.parent.mkdir(parents=True, exist_ok=True)
original_content = "user configuration or binary data without marker"
user_file.write_text(original_content, encoding="utf-8")
lines = []
_append_llama_server_shim_definition_lines(lines)
lines.append('_odysseus_create_llama_shim')
res = _run_shell(tmp_path, lines)
assert res.returncode != 0, "Creation should return non-zero when target is a user file"
assert user_file.read_text(encoding="utf-8") == original_content, "User file must remain byte-for-byte unchanged"
def test_odysseus_create_llama_shim_never_overwrites_user_symlink(tmp_path):
"""Creation boundary: never follow or overwrite a user-owned symlink."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
target_file = tmp_path / "user_target.txt"
target_file.write_text("precious user target content", encoding="utf-8")
user_symlink = bin_dir / "llama-server"
user_symlink.symlink_to(target_file)
lines = []
_append_llama_server_shim_definition_lines(lines)
lines.append('_odysseus_create_llama_shim')
res = _run_shell(tmp_path, lines)
assert res.returncode != 0, "Creation should return non-zero when target is a user symlink"
assert user_symlink.is_symlink(), "Symlink must remain intact"
assert target_file.read_text(encoding="utf-8") == "precious user target content", "Target file must not be modified"
def test_odysseus_create_llama_shim_never_overwrites_dangling_symlink(tmp_path):
"""Creation boundary: never overwrite a dangling user-owned symlink."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
dangling_symlink = bin_dir / "llama-server"
dangling_symlink.symlink_to(tmp_path / "nonexistent_target")
lines = []
_append_llama_server_shim_definition_lines(lines)
lines.append('_odysseus_create_llama_shim')
res = _run_shell(tmp_path, lines)
assert res.returncode != 0, "Creation should return non-zero when target is a dangling symlink"
assert dangling_symlink.is_symlink(), "Dangling symlink must remain intact"
def test_odysseus_create_llama_shim_safely_replaces_existing_odysseus_shim(tmp_path):
"""Creation boundary: safely replace an existing Odysseus-owned compatibility shim."""
shim_file = tmp_path / "bin/llama-server"
shim_file.parent.mkdir(parents=True, exist_ok=True)
shim_file.write_text(f"#!/bin/bash\n{ODYSSEUS_LLAMA_SHIM_MARKER}\n# old shim body\n", encoding="utf-8")
lines = []
_append_llama_server_shim_definition_lines(lines)
lines.append('_odysseus_create_llama_shim')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
new_content = shim_file.read_text(encoding="utf-8")
assert ODYSSEUS_LLAMA_SHIM_MARKER in new_content
assert "exec python3 -m llama_cpp.server" in new_content
def test_odysseus_create_llama_shim_secure_temp_prevents_symlink_attack(tmp_path):
"""Creation boundary: secure mktemp prevents predictable $$ temp symlink attacks.
Given:
- A victim file at ~/victim containing DO_NOT_TOUCH
- A pre-created symlink at ~/bin/.llama-server.odysseus.tmp.$$ pointing to ~/victim
- _odysseus_create_llama_shim executed within that exact same bash PID ($$)
Prove:
- ~/victim remains unmodified
- Pre-existing crafted symlink is NOT followed or overwritten
- Legitimate shim is successfully created at ~/bin/llama-server via secure temp path
"""
lines = [
'victim="$HOME/victim"',
'printf "DO_NOT_TOUCH\\n" > "$victim"',
'mkdir -p "$HOME/bin"',
'old_tmp="$HOME/bin/.llama-server.odysseus.tmp.$$";',
'ln -s "$victim" "$old_tmp"',
]
_append_llama_server_shim_definition_lines(lines)
lines.append('_odysseus_create_llama_shim')
lines.append('res=$?')
lines.append('echo "CREATION_RES=$res"')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert "CREATION_RES=0" in res.stdout
victim_file = tmp_path / "victim"
assert victim_file.exists()
assert victim_file.read_text(encoding="utf-8") == "DO_NOT_TOUCH\n", "Victim file must remain untouched"
target_shim = tmp_path / "bin/llama-server"
assert target_shim.exists()
assert ODYSSEUS_LLAMA_SHIM_MARKER in target_shim.read_text(encoding="utf-8")
assert target_shim.stat().st_mode & 0o111 != 0, "Shim must be executable"
def test_stale_odysseus_shim_removed_when_python_server_unusable(tmp_path):
"""Scenario 1: Stale Odysseus shim + missing Python server -> owned shim removed."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert not shim_file.exists(), "Stale Odysseus shim must be removed when python server is unavailable"
def test_healthy_odysseus_shim_retained_when_no_native_server(tmp_path):
"""Scenario 2: Healthy Odysseus shim + no native server -> shim preserved."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
res = _run_shell(tmp_path, lines, path=f"{git_bash_path(mock_py_dir)}:{os.environ.get('PATH', '')}")
assert res.returncode == 0
assert shim_file.exists(), "Healthy Odysseus shim must be preserved when no native server is present"
def test_reconciliation_does_not_retire_healthy_shim_for_broken_native_binary(tmp_path):
"""Reconciliation health check: a broken native candidate executable must NOT retire
a healthy Odysseus Python shim."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
_write_mock_native(tmp_path / "llama.cpp/build/bin/llama-server", succeeds=False)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
lines = [f'export PATH="$HOME/llama.cpp/build/bin:{git_bash_path(mock_py_dir)}:$PATH"']
_append_llama_server_shim_reconciliation_lines(lines)
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert shim_file.exists(), "Healthy Odysseus shim must NOT be retired when native binary is broken"
def test_odysseus_shim_retired_when_native_server_healthy(tmp_path):
"""Scenario 3: Odysseus shim + healthy native server available -> native server wins."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
_write_mock_native(tmp_path / "llama.cpp/build/bin/llama-server", succeeds=True)
lines = ['export PATH="$HOME/bin:$HOME/llama.cpp/build/bin:$PATH"']
_append_llama_server_shim_reconciliation_lines(lines)
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert not shim_file.exists(), "Odysseus shim must be retired when healthy native server is available"
def test_user_owned_bin_llama_server_never_touched(tmp_path):
"""Scenario 4: Arbitrary user ~/bin/llama-server -> untouched and not deprioritized."""
user_bin = tmp_path / "bin/llama-server"
user_bin.parent.mkdir(parents=True, exist_ok=True)
user_bin.write_text("#!/bin/bash\n# Custom user build script\nexec /opt/custom/llama-server\n", encoding="utf-8")
user_bin.chmod(0o755)
_write_mock_native(tmp_path / "llama.cpp/build/bin/llama-server", succeeds=True)
lines = ['export PATH="$HOME/bin:$HOME/llama.cpp/build/bin:$PATH"']
_append_llama_server_shim_reconciliation_lines(lines)
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert user_bin.exists(), "User-owned ~/bin/llama-server must never be deleted"
assert "Custom user build script" in user_bin.read_text(encoding="utf-8")
def test_reconciliation_ignores_native_binary_outside_effective_path(tmp_path):
"""Native candidate outside effective PATH must NOT be considered."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
# Native binary placed in /usr_custom/bin which is NOT in PATH
_write_mock_native(tmp_path / "usr_custom/bin/llama-server", succeeds=True)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
# PATH only contains ~/bin and mock_py_dir — usr_custom is omitted
path = f"{git_bash_path(tmp_path / 'bin')}:{git_bash_path(mock_py_dir)}"
res = _run_shell(tmp_path, lines, path=path)
assert res.returncode == 0
assert shim_file.exists(), "Shim must NOT be retired by native binary outside effective PATH"
def test_reconciliation_skips_home_bin_with_or_without_trailing_slash(tmp_path):
"""Reconciliation must exclude $HOME/bin and $HOME/bin/ from native candidate discovery."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
# PATH has ~/bin/ with trailing slash
path = f"{git_bash_path(tmp_path / 'bin')}/:{git_bash_path(mock_py_dir)}"
res = _run_shell(tmp_path, lines, path=path)
assert res.returncode == 0
assert shim_file.exists(), "Shim must NOT rediscover itself when PATH has trailing slash in $HOME/bin/"
def test_reconciliation_does_not_discover_cwd_executable_when_path_omits_dot(tmp_path):
"""Reconciliation must NOT synthesize an empty/trailing PATH element that searches cwd.
Given:
- Current working directory contains an executable `llama-server`
- Healthy Odysseus Python shim in ~/bin/llama-server
- PATH does NOT contain '.' or cwd
- No other healthy native binary on PATH
Prove:
- Reconciliation does not discover ./llama-server
- The healthy Odysseus shim in ~/bin/llama-server is preserved
"""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
cwd_dir = tmp_path / "workdir"
_write_mock_native(cwd_dir / "llama-server", succeeds=True)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
path = f"{git_bash_path(tmp_path / 'bin')}:{git_bash_path(mock_py_dir)}:/usr/bin:/bin"
res = _run_shell(tmp_path, lines, cwd=cwd_dir, path=path)
assert res.returncode == 0
assert shim_file.exists(), "Healthy Odysseus shim must NOT be retired by finding ./llama-server in cwd"
def test_reconciliation_discovers_healthy_native_on_explicit_other_path_entry(tmp_path):
"""Reconciliation must discover healthy native binaries placed on explicit PATH entries."""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
custom_opt_bin = tmp_path / "custom_opt/bin"
_write_mock_native(custom_opt_bin / "llama-server", succeeds=True)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
path = f"{git_bash_path(tmp_path / 'bin')}:{git_bash_path(custom_opt_bin)}:/usr/bin:/bin"
res = _run_shell(tmp_path, lines, path=path)
assert res.returncode == 0
assert not shim_file.exists(), "Odysseus shim must be retired when healthy native server is on explicit PATH entry"
def test_reconciliation_safely_iterates_path_with_glob_metacharacters(tmp_path):
"""Reconciliation must not perform pathname expansion / globbing on PATH components.
Given:
- A directory path containing shell glob characters: ~/custom[dir]/bin
- A native binary ~/custom[dir]/bin/llama-server
- A working directory containing matching glob entries
- An existing Odysseus shim in ~/bin/llama-server
Prove:
- Reconciliation correctly parses the literal path without expanding [dir]
- Discovers the healthy native server and retires the Odysseus shim
"""
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
glob_dir = tmp_path / "custom[dir]/bin"
_write_mock_native(glob_dir / "llama-server", succeeds=True)
cwd_dir = tmp_path / "cwd"
cwd_dir.mkdir(parents=True, exist_ok=True)
(cwd_dir / "customd").mkdir(parents=True, exist_ok=True)
(cwd_dir / "customi").mkdir(parents=True, exist_ok=True)
lines = []
_append_llama_server_shim_reconciliation_lines(lines)
path = f"{git_bash_path(tmp_path / 'bin')}:{git_bash_path(glob_dir)}:/usr/bin:/bin"
res = _run_shell(tmp_path, lines, cwd=cwd_dir, path=path)
assert res.returncode == 0
assert not shim_file.exists(), "Reconciliation must discover literal bracketed PATH without globbing"
def test_fallback_install_success_creates_usable_launcher(tmp_path):
"""Scenario 5: No native + no Python server -> fallback install succeeds
-> usable compatibility launcher exists and resolves command -v llama-server."""
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
lines = [f'export PATH="$HOME/bin:{git_bash_path(mock_py_dir)}:$PATH"']
_append_llama_server_shim_definition_lines(lines)
_append_llama_server_finalization_lines(lines)
lines.append('command -v llama-server')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
created_shim = tmp_path / "bin/llama-server"
assert created_shim.exists()
assert ODYSSEUS_LLAMA_SHIM_MARKER in created_shim.read_text(encoding="utf-8")
assert str(tmp_path / "bin/llama-server") in res.stdout
def test_failed_fallback_sets_preflight_exit_127(tmp_path):
"""Scenario 6: No native + failed fallback installation -> ODYSSEUS_PREFLIGHT_EXIT=127."""
lines = [
'ODYSSEUS_PREFLIGHT_EXIT=""',
'export PATH="$HOME/bin:$PATH"',
]
_append_llama_server_shim_definition_lines(lines)
_append_llama_server_finalization_lines(lines)
lines.append('echo "EXIT=$ODYSSEUS_PREFLIGHT_EXIT"')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert "EXIT=127" in res.stdout
def test_python_server_readiness_requires_llama_cpp_server_module():
"""Scenario 7 & 8: Python server readiness requires `import llama_cpp.server`,
while GPU offload capability checks still use base `import llama_cpp`."""
routes_source = _read("routes/cookbook_routes.py")
helpers_source = _read("routes/cookbook_helpers.py")
# Windows fallback readiness check requires .server
assert 'try { python -c "import llama_cpp.server" 2>$null } catch {}' in routes_source
# Termux fallback readiness check requires .server
assert 'if ! python3 -c "import llama_cpp.server" 2>/dev/null; then' in routes_source
# Build-failure fallback pip check requires .server
assert 'if [ -z "$_odysseus_llama_bin" ] && ! python3 -c "import llama_cpp.server" 2>/dev/null; then' in routes_source
# Shim reconciliation and post-install finalization require .server
assert 'elif python3 -c "import llama_cpp.server" 2>/dev/null; then' in helpers_source
assert 'if [ -z "$_odysseus_llama_bin" ] && python3 -c "import llama_cpp.server" 2>/dev/null; then' in helpers_source
# GPU offload support checks still intentionally inspect base llama_cpp
assert 'llama_cpp.llama_supports_gpu_offload()' in routes_source
def test_darwin_native_build_does_not_force_write_bin_symlink():
"""Darwin source build must not overwrite ~/bin/llama-server with a forced symlink."""
source = _read("routes/cookbook_routes.py")
assert "&& ln -sf ~/llama.cpp/build/bin/llama-server ~/bin/llama-server" not in source
assert 'cmake --build build -j"$NPROC" --target llama-server' in source
@pytest.mark.asyncio
async def test_generated_runner_preflight_retires_stale_shim_before_backend_selection(monkeypatch, tmp_path):
"""Integration regression: The actual generated Cookbook runner must execute
ownership reconciliation BEFORE evaluating the backend selection (`if Termux ... elif ! command -v llama-server`).
Given:
- Executable stale Odysseus shim in ~/bin/llama-server (with canonical marker)
- Healthy native llama-server in ~/llama.cpp/build/bin/llama-server
- ~/bin before ~/llama.cpp/build/bin on PATH
Prove:
1. Generated runner contains reconciliation shell lines strictly before the backend branching
2. Executing the generated runner script retires the stale ~/bin/llama-server shim
3. Resolves to the healthy native binary without attempting source build or pip fallback.
"""
from unittest.mock import AsyncMock, MagicMock
from routes.cookbook_helpers import ServeRequest
import routes.cookbook_routes as cookbook_routes
from starlette.requests import Request
fake_home = tmp_path / "fake_home"
stale_shim = fake_home / "bin/llama-server"
_write_odysseus_shim(stale_shim)
native_bin = fake_home / "llama.cpp/build/bin/llama-server"
_write_mock_native(native_bin, succeeds=True)
# Mock environment to generate the real runner file with isolated DB
tmux_log_dir = tmp_path / "tmux_logs"
tmux_log_dir.mkdir(parents=True)
monkeypatch.setattr(cookbook_routes, "TMUX_LOG_DIR", tmux_log_dir)
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
monkeypatch.setattr(cookbook_routes, "_binary_available", AsyncMock(return_value=True))
monkeypatch.setattr(cookbook_routes, "load_stored_hf_token", lambda **kwargs: "")
monkeypatch.setattr("core.database.SessionLocal", MagicMock())
class _Proc:
returncode = 0
stderr = None
async def wait(self):
return 0
monkeypatch.setattr(cookbook_routes.asyncio, "create_subprocess_shell", AsyncMock(return_value=_Proc()))
router = cookbook_routes.setup_cookbook_routes()
endpoint = None
for route in router.routes:
if route.path == "/api/model/serve" and "POST" in route.methods:
endpoint = route.endpoint
break
assert endpoint is not None
from types import SimpleNamespace
http_req = Request({
"type": "http",
"method": "POST",
"path": "/api/model/serve",
"headers": [],
"app": SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(
is_configured=True, is_admin=lambda user: user == "admin"))),
})
http_req.state.current_user = "admin"
http_req.state.local_model_authority = True
req = ServeRequest(
repo_id="test/repo",
cmd="llama-server -m /dummy/model.gguf --port 8085",
remote_host=None,
)
resp = await endpoint(http_req, req)
assert resp["ok"] is True
session_id = resp["session_id"]
runner_file = tmux_log_dir / f"{session_id}_run.sh"
assert runner_file.exists()
runner_script = runner_file.read_text(encoding="utf-8")
# Assert structural ordering in the generated script:
# Reconciliation must appear strictly BEFORE the Termux/backend check
recon_idx = runner_script.find("Native llama-server detected — retiring Odysseus compatibility shim in ~/bin")
termux_idx = runner_script.find("if [ -d /data/data/com.termux ]; then")
backend_idx = runner_script.find('elif [ -z "$_odysseus_llama_bin" ]; then')
assert recon_idx != -1, "Reconciliation block missing from generated runner"
assert termux_idx != -1, "Termux check missing from generated runner"
assert backend_idx != -1, "Backend selection missing from generated runner"
assert recon_idx < termux_idx < backend_idx, "Reconciliation must execute strictly before backend selection"
# Execute the generated runner preflight in a real subshell
res = _run_shell(fake_home, [str(runner_file)], path=f"{git_bash_path(fake_home / 'bin')}:{git_bash_path(fake_home / 'llama.cpp/build/bin')}:/usr/bin:/bin")
assert res.returncode == 0
assert not stale_shim.exists(), "Stale ~/bin/llama-server must have been retired by the generated preflight"
assert "RUN_NATIVE: -m /dummy/model.gguf --port 8085" in res.stdout
assert "Native llama-server not found — building from source" not in res.stdout
def test_broken_first_native_candidate_healthy_second_candidate_executes_healthy(tmp_path):
"""Regression finding P2: When PATH contains an earlier broken candidate followed by a
healthy candidate, reconciliation and launch must select and execute the healthy candidate."""
from core.platform_compat import git_bash_path
broken_dir = tmp_path / "broken_bin"
_write_mock_native(broken_dir / "llama-server", succeeds=False)
healthy_dir = tmp_path / "healthy_bin"
_write_mock_native(healthy_dir / "llama-server", succeeds=True)
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
lines = [
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(healthy_dir)}:/usr/bin:/bin"',
]
_append_llama_server_shim_reconciliation_lines(lines)
lines.append('echo "RESOLVED=$_odysseus_llama_bin"')
lines.append('command -v llama-server')
lines.append('llama-server -m /dummy/model.gguf --port 8080')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert not shim_file.exists(), "Stale Odysseus shim must be retired"
assert f"RESOLVED={git_bash_path(healthy_dir / 'llama-server')}" in res.stdout
assert str(healthy_dir / "llama-server") in res.stdout
assert "RUN_NATIVE: -m /dummy/model.gguf --port 8080" in res.stdout
assert "error: missing libllama.so" not in res.stdout
def test_paths_with_spaces_broken_first_healthy_second_launch(tmp_path):
"""Regression: Paths containing spaces must be safely resolved, prioritized and launched."""
from core.platform_compat import git_bash_path
broken_dir = tmp_path / "broken path with spaces/bin"
_write_mock_native(broken_dir / "llama-server", succeeds=False)
healthy_dir = tmp_path / "healthy path with spaces/bin"
_write_mock_native(healthy_dir / "llama-server", succeeds=True)
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
model_dir = tmp_path / "models with spaces"
model_dir.mkdir(parents=True, exist_ok=True)
model_file = model_dir / "my model.gguf"
model_file.write_text("dummy", encoding="utf-8")
lines = [
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(healthy_dir)}:/usr/bin:/bin"',
]
_append_llama_server_shim_reconciliation_lines(lines)
lines.append(f'llama-server -m "{git_bash_path(model_file)}" --port 8080')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert not shim_file.exists(), "Stale shim must be retired"
assert f"RUN_NATIVE: -m {git_bash_path(model_file)} --port 8080" in res.stdout
assert "error: missing libllama.so" not in res.stdout
def test_command_hash_invalidation_switches_from_cached_broken_to_healthy(tmp_path):
"""Regression: Command cache invalidation (`hash -r`) must prevent bash from reusing a
previously cached lookup to an earlier broken executable."""
from core.platform_compat import git_bash_path
broken_dir = tmp_path / "broken_bin"
_write_mock_native(broken_dir / "llama-server", succeeds=False)
healthy_dir = tmp_path / "healthy_bin"
_write_mock_native(healthy_dir / "llama-server", succeeds=True)
lines = [
# Populate bash hash cache with broken binary
f'export PATH="{git_bash_path(broken_dir)}:/usr/bin:/bin"',
'llama-server >/dev/null 2>&1 || true',
'hash',
# Now add healthy candidate later in PATH and run reconciliation
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(healthy_dir)}:/usr/bin:/bin"',
]
_append_llama_server_shim_reconciliation_lines(lines)
lines.append('llama-server -m /dummy/test.gguf')
lines.append('hash')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert "RUN_NATIVE: -m /dummy/test.gguf" in res.stdout
# Verify hash table ended with healthy binary, not broken
assert str(healthy_dir / "llama-server") in res.stdout
def test_broken_native_candidate_falls_back_to_healthy_python_compatibility_shim(tmp_path):
"""Regression: A broken native candidate must not shadow or retire a healthy Python shim."""
from core.platform_compat import git_bash_path
broken_dir = tmp_path / "broken_bin"
_write_mock_native(broken_dir / "llama-server", succeeds=False)
shim_file = tmp_path / "bin/llama-server"
_write_odysseus_shim(shim_file)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=True)
lines = [
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(mock_py_dir)}:/usr/bin:/bin"',
]
_append_llama_server_shim_reconciliation_lines(lines)
lines.append('echo "RESOLVED=$_odysseus_llama_bin"')
lines.append('command -v llama-server')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert shim_file.exists(), "Healthy Python compatibility shim must be preserved"
assert f"RESOLVED={git_bash_path(shim_file)}" in res.stdout
assert str(shim_file) in res.stdout
def test_broken_native_and_missing_python_backend_sets_preflight_127(tmp_path):
"""Regression: When the only native candidates are broken and Python server is unavailable,
finalization must fail closed with ODYSSEUS_PREFLIGHT_EXIT=127 rather than claiming readiness."""
from core.platform_compat import git_bash_path
broken_dir = tmp_path / "broken_bin"
_write_mock_native(broken_dir / "llama-server", succeeds=False)
mock_py_dir = tmp_path / "mock_bin"
_write_mock_python3(mock_py_dir / "python3", succeeds=False)
lines = [
'ODYSSEUS_PREFLIGHT_EXIT=""',
f'export PATH="$HOME/bin:{git_bash_path(broken_dir)}:{git_bash_path(mock_py_dir)}:/usr/bin:/bin"',
]
_append_llama_server_shim_definition_lines(lines)
_append_llama_server_shim_reconciliation_lines(lines)
_append_llama_server_finalization_lines(lines)
lines.append('echo "EXIT=$ODYSSEUS_PREFLIGHT_EXIT"')
res = _run_shell(tmp_path, lines)
assert res.returncode == 0
assert "EXIT=127" in res.stdout
@pytest.mark.asyncio
async def test_generated_runner_broken_first_healthy_second_e2e(monkeypatch, tmp_path):
"""Integration regression: The full generated Cookbook runner script must launch the healthy
native candidate even when an earlier broken candidate exists on PATH."""
from unittest.mock import AsyncMock, MagicMock
from types import SimpleNamespace
from routes.cookbook_helpers import ServeRequest
import routes.cookbook_routes as cookbook_routes
from starlette.requests import Request
from core.platform_compat import git_bash_path
fake_home = tmp_path / "fake_home"
stale_shim = fake_home / "bin/llama-server"
_write_odysseus_shim(stale_shim)
broken_bin = fake_home / "broken/bin/llama-server"
_write_mock_native(broken_bin, succeeds=False)
healthy_bin = fake_home / "healthy/bin/llama-server"
_write_mock_native(healthy_bin, succeeds=True)
tmux_log_dir = tmp_path / "tmux_logs"
tmux_log_dir.mkdir(parents=True)
monkeypatch.setattr(cookbook_routes, "TMUX_LOG_DIR", tmux_log_dir)
monkeypatch.setattr(cookbook_routes, "require_admin", lambda request: None)
monkeypatch.setattr(cookbook_routes, "_binary_available", AsyncMock(return_value=True))
monkeypatch.setattr(cookbook_routes, "load_stored_hf_token", lambda **kwargs: "")
monkeypatch.setattr("core.database.SessionLocal", MagicMock())
class _Proc:
returncode = 0
stderr = None
async def wait(self):
return 0
monkeypatch.setattr(cookbook_routes.asyncio, "create_subprocess_shell", AsyncMock(return_value=_Proc()))
router = cookbook_routes.setup_cookbook_routes()
endpoint = None
for route in router.routes:
if route.path == "/api/model/serve" and "POST" in route.methods:
endpoint = route.endpoint
break
assert endpoint is not None
http_req = Request({
"type": "http",
"method": "POST",
"path": "/api/model/serve",
"headers": [],
"app": SimpleNamespace(state=SimpleNamespace(auth_manager=SimpleNamespace(
is_configured=True, is_admin=lambda user: user == "admin"))),
})
http_req.state.current_user = "admin"
http_req.state.local_model_authority = True
req = ServeRequest(
repo_id="test/repo",
cmd="llama-server -m /dummy/model.gguf --port 8090",
remote_host=None,
)
resp = await endpoint(http_req, req)
assert resp["ok"] is True
session_id = resp["session_id"]
runner_file = tmux_log_dir / f"{session_id}_run.sh"
assert runner_file.exists()
# Broken is earlier on PATH than healthy
res = _run_shell(
fake_home,
[str(runner_file)],
path=f"{git_bash_path(fake_home / 'bin')}:{git_bash_path(broken_bin.parent)}:{git_bash_path(healthy_bin.parent)}:/usr/bin:/bin",
)
assert res.returncode == 0
assert not stale_shim.exists(), "Stale shim must have been retired"
assert "RUN_NATIVE: -m /dummy/model.gguf --port 8090" in res.stdout
assert "error: missing libllama.so" not in res.stdout
assert "Native llama-server not found — building from source" not in res.stdout
@@ -93,7 +93,7 @@ def test_custom_filter_reuses_convolve_kernel():
assert "convolve(readSource(snap), k, divisor, bias, 1)" in EFFECTS
def test_blur_and_custom_filter_aliases_registered():
def test_new_filter_aliases_registered():
assert "'box-blur': 'blur-box'" in EDITOR
assert "'surface-blur': 'blur-surface'" in EDITOR
assert "custom: 'other-custom'" in EDITOR
+71
View File
@@ -39,3 +39,74 @@ def test_http_transport_routes_to_start_http_connect():
result = asyncio.run(mgr.connect_server("id1", "n", "http", url="https://x/mcp"))
assert result == "ROUTED"
m.assert_called_once()
def _patch_http_transport(monkeypatch, initialize):
"""Fake the Streamable HTTP transport and record, for each close, whether
it ran in the task that opened it (AnyIO requires that)."""
import contextlib
import mcp
import mcp.client.streamable_http as streamable_http
import src.mcp_oauth as mcp_oauth
closes = []
@contextlib.asynccontextmanager
async def fake_client(url, auth=None):
opener = asyncio.current_task()
try:
yield (object(), object(), lambda: None)
finally:
closes.append(asyncio.current_task() is opener)
class FakeSession:
def __init__(self, *a):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
FakeSession.initialize = initialize
monkeypatch.setattr(streamable_http, "streamablehttp_client", fake_client)
monkeypatch.setattr(mcp, "ClientSession", FakeSession)
monkeypatch.setattr(mcp_oauth, "build_provider", lambda *a, **k: None)
return closes
def test_failed_http_connect_closes_transport_in_its_own_task(monkeypatch):
"""#5518: a failed connect left its transport for the GC, which exited the
AnyIO cancel scope from the wrong task and kept the event loop spinning."""
async def initialize(self):
raise RuntimeError("Session terminated")
closes = _patch_http_transport(monkeypatch, initialize)
mgr = McpManager()
ok = asyncio.run(mgr._start_http_connect("s1", "remote", "http://x/mcp/", wait=2))
assert ok is False
assert mgr._connections["s1"]["status"] == "error"
assert "Session terminated" in mgr._connections["s1"]["error"]
assert closes == [True]
assert "s1" not in mgr._stacks
def test_cancelled_http_connect_closes_transport(monkeypatch):
"""Deleting a server mid-connect cancels the connect task; the transport
must still be closed rather than leaked."""
async def initialize(self):
await asyncio.sleep(3600)
closes = _patch_http_transport(monkeypatch, initialize)
mgr = McpManager()
async def run():
await mgr._start_http_connect("s1", "remote", "http://x/mcp/", wait=0.05)
await mgr.disconnect_server("s1")
await asyncio.sleep(0)
asyncio.run(run())
assert closes == [True]
-43
View File
@@ -1,43 +0,0 @@
"""A detached Notes descendant must not hold output open or kill a bystander."""
import os
import runpy
import subprocess
import sys
from pathlib import Path
import pytest
from src import process_lifecycle as lifecycle
ROOT = Path(__file__).resolve().parents[1]
@pytest.mark.skipif(os.name == "nt", reason="POSIX detached-session regression; Windows uses taskkill tree")
def test_notes_cleanup_reaps_detached_pipe_holder_and_preserves_bystander():
stop = runpy.run_path(str(ROOT / "scripts/verify_notes_browser_runtime.py"))["_stop_owned_server"]
child = "import time; time.sleep(60)"
program = ("import subprocess,sys,time; "
f"p=subprocess.Popen([sys.executable,'-c',{child!r}],start_new_session=True); "
"print(p.pid,flush=True); time.sleep(60)")
with subprocess.Popen([sys.executable, "-c", child]) as bystander:
server = subprocess.Popen([sys.executable, "-c", program], stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, text=True, start_new_session=True)
root_identity = lifecycle.ProcessIdentity.capture(server.pid)
detached = None
try:
child_pid = int(server.stdout.readline())
detached = lifecycle.ProcessIdentity.capture(child_pid)
assert os.getpgid(child_pid) == child_pid
stop(server, root_identity)
assert server.poll() is not None
assert detached.exited()
assert bystander.poll() is None, "cleanup touched an unrelated process"
finally:
# Test-owned fallback cleanup also runs when the negative control
# intentionally substitutes the old parent-only teardown.
targets = [identity for identity in (detached, root_identity) if identity]
lifecycle.terminate_identities(targets, steps=lifecycle.term_kill_steps(1))
server.wait(timeout=3)
if server.stdout:
server.stdout.close()
bystander.terminate()
bystander.wait(timeout=3)
@@ -303,52 +303,3 @@ def test_teardown_rejects_unconfined_programmatic_paths():
res = _run_teardown(options={"resultsDir": "/etc"})
assert res.returncode == 1
assert "Security rejection" in res.stderr
def _disposable_ci_fixture(tmp_path):
data = tmp_path / "odysseus-photo-editor-e2e-fixture"
data.mkdir()
database = data / "app.db"
(data / ".disposable-ci-fixture.json").write_text(json.dumps({
"synthetic": True, "authenticated": True, "database": str(database),
}))
return {"CI": "true", "GITHUB_ACTIONS": "true",
"ODYSSEUS_E2E_DISPOSABLE_ARTIFACTS": "1",
"PHOTO_EDITOR_E2E_DATA_DIR": str(data), "PHOTO_EDITOR_E2E_DB_PATH": str(database),
"PHOTO_EDITOR_E2E_AUTH": "1", "ODYSSEUS_TEST_PYTHON": sys.executable}
def test_disposable_ci_retains_only_sanitized_failure_artifacts(tmp_path):
results = tmp_path / "results"
results.mkdir()
trace = results / "trace.zip"
with zipfile.ZipFile(trace, "w") as archive:
archive.writestr("0-trace.trace", json.dumps({"type": "action", "name": "fill", "params": {"value": "synthetic-secret"}}) + "\n")
screenshot = results / "failure.png"
screenshot.write_bytes(b"synthetic screenshot")
response = _run_teardown(_disposable_ci_fixture(tmp_path), {"resultsDir": str(results)})
assert response.returncode == 0, response.stderr
with zipfile.ZipFile(trace) as archive:
assert json.loads(archive.read("0-trace.trace"))["params"]["value"] == "[REDACTED]"
assert screenshot.exists()
@pytest.mark.parametrize("defect", ["missing-marker", "wrong-database", "unauthenticated", "sanitizer-failure"])
def test_disposable_ci_artifact_failures_purge_closed(tmp_path, defect):
results = tmp_path / "results"
results.mkdir()
(results / "trace.zip").write_bytes(b"invalid sensitive archive")
(results / "failure.png").write_bytes(b"sensitive screenshot")
environment = _disposable_ci_fixture(tmp_path)
if defect == "missing-marker":
(Path(environment["PHOTO_EDITOR_E2E_DATA_DIR"]) / ".disposable-ci-fixture.json").unlink()
elif defect == "wrong-database":
environment["PHOTO_EDITOR_E2E_DB_PATH"] = str(tmp_path / "other.db")
elif defect == "unauthenticated":
environment["PHOTO_EDITOR_E2E_AUTH"] = "0"
else:
environment["ODYSSEUS_TEST_PYTHON"] = "/missing-python"
response = _run_teardown(environment, {"resultsDir": str(results)})
assert response.returncode == 0, response.stderr
assert not (results / "trace.zip").exists()
assert not (results / "failure.png").exists()
+34 -155
View File
@@ -12,8 +12,6 @@ exercising every procfs-touching code path on both a Linux and a non-Linux
host. ``test_cookbook_stop_without_procfs.py`` covers the behaviour itself.
"""
import ast
import pytest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -77,112 +75,37 @@ def _scan_sites(tree: ast.AST) -> list[ast.Call]:
return sites
def _condition_requires_procfs(node: ast.AST, truth: bool) -> bool:
"""Whether this Boolean outcome proves a direct procfs check succeeded.
An ignored call, another function's check, or a check on only one possible
OR branch provides no proof. Keep unknown expressions conservative.
"""
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.Not):
return _condition_requires_procfs(node.operand, not truth)
if isinstance(node, ast.BoolOp):
facts = [_condition_requires_procfs(value, truth) for value in node.values]
all_values_required = isinstance(node.op, ast.And) == truth
return any(facts) if all_values_required else all(facts)
if not isinstance(node, ast.Call):
return False
func = node.func
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
subject_is_procfs = (
name == "has_procfs"
or (isinstance(func, ast.Attribute) and _is_procfs_root(func.value))
or any(_is_procfs_root(arg) for arg in node.args)
)
return truth and name in _GUARD_FUNCS and subject_is_procfs
def _guard_lines(tree: ast.AST) -> list[int]:
"""Line numbers of calls that test whether procfs is present."""
lines = []
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
func = node.func
name = func.attr if isinstance(func, ast.Attribute) else getattr(func, "id", "")
if name not in _GUARD_FUNCS:
continue
subject_is_procfs = (
name in {"has_procfs", "is_wsl"}
or (isinstance(func, ast.Attribute) and _is_procfs_root(func.value))
or any(_is_procfs_root(arg) for arg in node.args)
)
if subject_is_procfs:
lines.append(node.lineno)
return lines
def _guarded_scan_ids(tree: ast.AST) -> set[int]:
"""Follow conditional/early-exit paths instead of comparing line numbers.
Each function starts with no procfs assumption. Facts from a loop or try
body cannot escape to code which may run without that body completing.
This intentionally requires direct checks; arbitrary aliases/call graphs
are not a proof that procfs exists at the enumeration site.
"""
scans = {id(site) for site in _scan_sites(tree)}
guarded = set()
def expression(node, present):
if node is None:
return
if id(node) in scans and present:
guarded.add(id(node))
if isinstance(node, ast.BoolOp):
for value in node.values:
expression(value, present)
# Later AND operands run only after a true prefix; later OR
# operands run only after a false prefix.
present = present or _condition_requires_procfs(value, isinstance(node.op, ast.And))
elif isinstance(node, ast.IfExp):
expression(node.test, present)
expression(node.body, present or _condition_requires_procfs(node.test, True))
expression(node.orelse, present or _condition_requires_procfs(node.test, False))
elif isinstance(node, (ast.ListComp, ast.SetComp, ast.GeneratorExp, ast.DictComp)):
for generator in node.generators:
expression(generator.iter, present)
for condition in generator.ifs:
expression(condition, present)
present = present or _condition_requires_procfs(condition, True)
for attr in ("elt", "key", "value"):
expression(getattr(node, attr, None), present)
elif isinstance(node, ast.Lambda):
expression(node.body, False)
else:
for child in ast.iter_child_nodes(node):
expression(child, present)
def block(statements, present):
for statement in statements:
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
block(statement.body, False)
for value in statement.decorator_list + statement.args.defaults:
expression(value, present)
elif isinstance(statement, ast.ClassDef):
block(statement.body, present)
elif isinstance(statement, ast.If):
expression(statement.test, present)
yes = block(statement.body, present or _condition_requires_procfs(statement.test, True))
no = block(statement.orelse, present or _condition_requires_procfs(statement.test, False))
continuing = [fact for fact in (yes, no) if fact is not None]
if not continuing:
return None
present = all(continuing)
elif isinstance(statement, (ast.Return, ast.Raise, ast.Break, ast.Continue)):
for child in ast.iter_child_nodes(statement):
expression(child, present)
return None
elif isinstance(statement, (ast.For, ast.AsyncFor, ast.While)):
condition = statement.test if isinstance(statement, ast.While) else statement.iter
expression(condition, present)
body_present = present or (isinstance(statement, ast.While) and _condition_requires_procfs(condition, True))
block(statement.body, body_present)
block(statement.orelse, present)
elif isinstance(statement, (ast.Try, getattr(ast, "TryStar", ast.Try))):
block(statement.body, present)
block(statement.orelse, present)
block(statement.finalbody, present)
for handler in statement.handlers:
block(handler.body, present)
elif isinstance(statement, (ast.With, ast.AsyncWith)):
for item in statement.items:
expression(item.context_expr, present)
block(statement.body, present)
else:
expression(statement, present)
return present
block(tree.body, False)
return guarded
def _enclosing_scope(tree: ast.AST, node: ast.AST) -> ast.AST:
"""Smallest function/module scope containing ``node``."""
best = tree
for candidate in ast.walk(tree):
if not isinstance(candidate, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
end = getattr(candidate, "end_lineno", None) or candidate.lineno
if candidate.lineno <= node.lineno <= end:
if best is tree or candidate.lineno > best.lineno:
best = candidate
return best
def _app_python_files() -> list[Path]:
@@ -216,10 +139,12 @@ def _collect_sites() -> tuple[list[str], list[str]]:
sites = _scan_sites(tree)
if not sites:
continue
guards = _guarded_scan_ids(tree)
guards = _guard_lines(tree)
for site in sites:
scope = _enclosing_scope(tree, site)
start = getattr(scope, "lineno", 0)
label = f"{path.relative_to(REPO_ROOT)}:{site.lineno}"
if id(site) in guards:
if any(start <= g < site.lineno for g in guards):
guarded.append(label)
else:
unguarded.append(label)
@@ -229,7 +154,7 @@ def _collect_sites() -> tuple[list[str], list[str]]:
def test_every_procfs_scan_is_guarded_by_an_existence_check():
_guarded, unguarded = _collect_sites()
assert not unguarded, (
"procfs pid scans without an effective conditional existence guard — "
"procfs pid scans with no existence check in the enclosing function — "
"these raise FileNotFoundError on macOS and Windows: "
+ ", ".join(unguarded)
)
@@ -251,49 +176,3 @@ def test_the_guard_detector_still_sees_the_known_scans():
f"root was renamed. Found: {sorted(found)}"
)
assert len(found) >= 3, f"expected at least 3 procfs scans, found {sorted(found)}"
@pytest.mark.parametrize("body", [
'Path("/proc").exists(); os.listdir("/proc")',
'if Path("/proc").exists():\n pass\nos.listdir("/proc")',
'if not Path("/proc").exists():\n os.listdir("/proc")',
'if ready or Path("/proc").exists():\n os.listdir("/proc")',
'if ready:\n Path("/proc").exists()\nos.listdir("/proc")',
'os.listdir("/proc"); Path("/proc").exists()',
'if Path("/tmp").exists():\n os.listdir("/proc")',
'def other():\n Path("/proc").exists()\nos.listdir("/proc")',
'if not has_procfs():\n pass\nos.listdir("/proc")',
'for x in []:\n if not has_procfs():\n return\nos.listdir("/proc")',
'if has_procfs():\n return\nelse:\n os.listdir("/proc")',
'[pid for pid in os.listdir("/proc") if has_procfs()]',
], ids=["ignored", "expired-branch", "inverted", "unsafe-or", "sibling-branch",
"too-late", "wrong-path", "other-function", "no-early-exit", "loop-may-not-run",
"wrong-else", "late-comprehension-filter"])
def test_guard_detector_rejects_ineffective_checks(body):
tree = ast.parse("def scan():\n" + "\n".join(" " + line for line in body.splitlines()))
sites = _scan_sites(tree)
assert sites, "negative control must contain a recognized enumeration"
assert any(id(site) not in _guarded_scan_ids(tree) for site in sites)
@pytest.mark.parametrize("body", [
'if Path("/proc").exists():\n os.listdir("/proc")',
'if not Path("/proc").exists():\n return\nos.listdir("/proc")',
'if not has_procfs():\n raise RuntimeError()\nos.listdir(PROC_ROOT)',
'if has_procfs():\n pass\nelse:\n return\nPROC_ROOT.iterdir()',
'if ready and has_procfs():\n os.scandir("/proc")',
'if not has_procfs() or disabled:\n return\nPROC_ROOT.glob("*")',
'if sys.platform == "linux" and has_procfs():\n PROC_ROOT.iterdir()',
'for item in items:\n if not has_procfs():\n continue\n os.listdir("/proc")',
'if os.path.isdir("/proc"):\n os.listdir("/proc")',
'if has_procfs() and list(PROC_ROOT.iterdir()):\n pass',
'if not has_procfs() or list(PROC_ROOT.iterdir()):\n pass',
'[pid for item in items if has_procfs() for pid in os.listdir("/proc")]',
], ids=["positive-branch", "early-return", "early-raise", "else-exits", "and",
"negative-or", "platform-and-procfs", "loop-continue", "isdir",
"short-circuit-and", "short-circuit-or", "guarded-comprehension"])
def test_guard_detector_preserves_effective_checks(body):
tree = ast.parse("def scan():\n" + "\n".join(" " + line for line in body.splitlines()))
sites = _scan_sites(tree)
assert sites, "positive control must contain a recognized enumeration"
assert all(id(site) in _guarded_scan_ids(tree) for site in sites)