v5.1.0: feat(chat+ci) — selective port from PR #25 + chat bottom-fade layer fix + retire ouroboros-three-layer

Selectively port 5 PR #25 commits into ouroboros (clipboard image
paste, autocorrect-off on chat textarea, integration-test CI tier,
optional macOS code signing & notarization, secrets→env fix for
step-level if-conditions in GitHub Actions. Plus chat bottom
gradient migration from #chat-input-area's background to a dedicated
.chat-bottom-fade sibling layer (z-index 4, pointer-events:none) so
the textarea no longer optically sinks into the dense end of the
gradient. Plus retire ouroboros-three-layer as a dev branch — ouroboros
is now the single dev branch.

(1) Clipboard image paste: web/modules/chat.js registers a paste
listener on #chat-input that scans e.clipboardData.items for image/*,
calls getAsFile(), wraps as File(clipboard-<unix-ts>.<ext>), and
stages via the same pendingAttachment slot the paperclip uses (no
inline upload — uploads when Send/Enter fires). Non-image paste falls
through natively. The paperclip change handler was extracted into a
shared stagePendingFile() helper so both entry points are identical.
The textarea gains autocorrect=off autocapitalize=off
spellcheck=false so code/identifiers/slash-commands are not silently
rewritten by the browser.

(2) Chat bottom-fade layer: web/style.css strips the linear-gradient
background and mask-image from #chat-input-area (which keeps z-index 5),
and adds a new dedicated sibling .chat-bottom-fade (position:absolute;
bottom:0; pointer-events:none; z-index:4; height:200px) below the
input dock. Mobile @media (max-width: 640px) uses
calc(200px + env(safe-area-inset-bottom, 0px)) so the fade fully
covers the iOS-home-indicator safe area at the worst-case input-area
state (attachment + fully-expanded textarea).

(3) Integration tier in CI (Tier 2.5): new integration-test job runs
pytest tests/test_provider_integration.py -m integration on
ubuntu-latest with OPENROUTER_API_KEY/OPENAI_API_KEY/ANTHROPIC_API_KEY
in repo secrets. Triggered on push to main / ouroboros / ouroboros-stable,
on workflow_dispatch, and on tag v*. Locally the  pytest
marker plus addopts -m 'not integration' in pyproject.toml exclude
the tests from default runs.

(4) Optional macOS code signing & notarization (Build tier): when
BUILD_CERTIFICATE_BASE64 / P12_PASSWORD / KEYCHAIN_PASSWORD /
APPLE_TEAM_ID are configured as repo secrets, the build job creates
a temporary keychain, imports the Developer ID certificate, and runs
bash build.sh (which signs .app and .dmg via env-overridable
SIGN_IDENTITY). With APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD also
present, build.sh runs xcrun notarytool submit --wait followed by
xcrun stapler staple. Stapler/notarytool failures are wrapped in
if/else (set -e exempt) so transient Apple-CDN flakes become warnings
instead of dropping the macOS DMG from the release. A NOTARIZE_OUTCOME
enum drives a 4-case summary cascade (success / staple_failed /
submit_failed / unconfigured) plus a defensive *) arm. With no Apple
secrets the build falls back to OUROBOROS_SIGN=0 bash build.sh
(identical to v5.0.0). Cleanup keychain step runs with
if: always() && matrix.os == 'macos-latest' &&
env.BUILD_CERTIFICATE_BASE64 != '' so signing material never persists
across runs and the bash-only security delete-keychain invocation
never fires on Linux/Windows shards. The Import step sets
trap 'rm -f "$CERTIFICATE_PATH"' EXIT so the temporary .p12 is
removed on every exit, including a set -e abort mid-import.

(5) secrets→env fix for step-level if (v4.47.1 lesson): GitHub Actions
rejects secrets.* references inside step-level if expressions
(Unrecognized named-value: secrets). All Apple signing secrets
are mapped at the build job's env: block with a
${{ matrix.os == 'macos-latest' && secrets.X || '' }} guard so
non-macOS shards receive empty strings — Linux/Windows never see the
signing material. Step-level if reads env.* instead. New
docs/DEVELOPMENT.md section GitHub Actions: secrets in step-level if
conditions formalizes the rule with worked examples.

(6) Tests: tests/test_chat_logs_ui.py gains 3 new tests
(test_chat_input_disables_autocorrect, test_clipboard_paste_handler_exists,
test_chat_bottom_fade_is_separate_layer); tests/test_build_scripts.py
gains a new TestMacOSSigning class with 7 contract tests (job-level
secrets env mapping with matrix.os guard, no secrets.* in any if-block,
Import step gates on full secret set, Cleanup keychain always() +
matrix.os + env guard, build.sh SIGN_IDENTITY env override, notarytool
+ stapler optional gate, stapler-failure-as-soft-warning regression);
tests/test_provider_integration.py is added new with 6 tuple-aware
tests (OpenRouter / OpenAI / Anthropic × {basic, isolation}) handling
the post-v4.44.0 LLMClient.chat() (msg, usage) tuple plus Anthropic's
list-of-blocks content. The existing
test_chat_floating_overlays_have_readable_glass_backing was updated
for the migrated bottom-fade contract (asserts no backdrop-filter on
.chat-bottom-fade across base + mobile @media rules).

(7) Branch consolidation: ouroboros-three-layer is retired as a dev
branch. ouroboros is now the single dev branch.
.github/workflows/ci.yml (Tier 1 quick-test trigger + path-filter
branches list + build job's OUROBOROS_MANAGED_SOURCE_BRANCH default),
build.sh / build_linux.sh / build_windows.ps1 (each script's
${OUROBOROS_MANAGED_SOURCE_BRANCH:-...} default), and four test files
(test_release_workflow.py, test_launcher_sync.py,
test_git_ops_recovery.py, test_build_repo_bundle.py — 21 occurrences
total) all switch from ouroboros-three-layer to ouroboros. Historical
references in older changelog rows (v4.50.0-rc.7) and in ouroboros/*
module comments about the Phase 2/3 three-layer architecture refactor
are intentionally preserved — those describe the architectural
refactor, not the dev branch name. The remote
managed/ouroboros-three-layer branch is deleted in the same release.

Adversarial multimodel review (gemini-2.5/gpt-5.5/claude-opus-4.7
critics in parallel, full-context, 4 rounds): 32 findings total → 18
fixed, 14 rejected/deferred with explicit per-finding reasoning. All
three critics independently reach SAFE TO COMMIT after round 4.

Ouroboros triad+scope review (production code path
parallel_review.run_parallel_review with full-repo pack, 2 rounds):
4 findings (2 scope-critical + 1 scope-advisory + 1 triad-advisory)
→ all 4 fixed. Round 1 caught matrix-shard secret leak; round 2
caught documentation/runtime command mismatch + cert-file cleanup
gap on set -e failure.

VERSION 5.0.0 → 5.1.0 (MINOR: additive features + UX/CI polish, no
breaking change). Release invariant synchronised: VERSION,
pyproject.toml [project].version, README badge, docs/ARCHITECTURE.md
header — all 5.1.0.

Note on changelog rolloff: the v4.50.0-rc.2 minor entry is rolled
off proactively to keep one slot below the P7 5-minor-row cap. Its
full body remains at git tag v4.50.0-rc.2.
EOF
)
This commit is contained in:
Ouroboros 2026-04-26 17:57:30 +03:00
parent ce042704db
commit adac2e0b4e
18 changed files with 1072 additions and 80 deletions

View file

@ -1,8 +1,13 @@
# Ouroboros CI — Three-tier cross-platform testing and release pipeline
# Ouroboros CI — Four-tier cross-platform testing and release pipeline
#
# Tier 1: Every push to ouroboros / ouroboros-three-layer (code paths) → Ubuntu-only tests (~1 min)
# Tier 2: Push to ouroboros-stable / manual / tag → Full 3-OS matrix (~5 min)
# Tier 3: Tag v* → Full matrix + build artifacts + GitHub Release (~15 min)
# Tier 1 (Quick): Push to ouroboros (code paths) → Ubuntu-only tests (~1 min)
# Tier 2 (Full): Push to ouroboros-stable / manual / tag → Full 3-OS matrix (~5 min)
# Tier 2.5 (Integration): Push to main / ouroboros / ouroboros-stable / manual / tag → Real-provider tests (~2 min)
# Tier 3 (Build+Release): Tag v* → PyInstaller + GitHub Release (~15 min)
#
# Tier 2.5 requires OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY in
# repository secrets and runs the `integration` pytest marker; locally these
# tests are excluded by `addopts = -m 'not integration'` in pyproject.toml.
name: CI
@ -10,7 +15,7 @@ name: CI
# This ensures tag pushes always fire (even if only VERSION/README changed).
on:
push:
branches: [ouroboros, ouroboros-three-layer, ouroboros-stable]
branches: [main, ouroboros, ouroboros-stable]
paths:
- 'ouroboros/**'
- 'supervisor/**'
@ -37,15 +42,12 @@ on:
jobs:
# ──────────────────────────────────────────────────────────────────
# Tier 1: Quick tests on Ubuntu (every push to ouroboros / ouroboros-three-layer)
# Tier 1: Quick tests on Ubuntu (every push to ouroboros)
# ──────────────────────────────────────────────────────────────────
quick-test:
if: |
github.event_name == 'push'
&& (
github.ref == 'refs/heads/ouroboros'
|| github.ref == 'refs/heads/ouroboros-three-layer'
)
&& github.ref == 'refs/heads/ouroboros'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@ -86,6 +88,42 @@ jobs:
- name: Run tests
run: python -m pytest tests/ -q --tb=short
# ──────────────────────────────────────────────────────────────────
# Tier 2.5: Integration tests against real provider APIs
# Triggered on push to main / ouroboros / ouroboros-stable, manual,
# or tag v*. Requires OPENROUTER_API_KEY / OPENAI_API_KEY /
# ANTHROPIC_API_KEY in repository secrets. The `integration` pytest
# marker (in pyproject.toml) controls inclusion via `-m integration`;
# within an included test file, missing-key skipping is done by per-
# test `@pytest.mark.skipif(not os.environ.get(KEY))` decorators (see
# tests/test_provider_integration.py). NOT a `needs:` of build/
# release: a provider outage must not block a tagged release.
# ──────────────────────────────────────────────────────────────────
integration-test:
if: |
github.event_name == 'workflow_dispatch'
|| github.ref == 'refs/heads/main'
|| github.ref == 'refs/heads/ouroboros'
|| github.ref == 'refs/heads/ouroboros-stable'
|| startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.10'
cache: 'pip'
- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest
- name: Run integration tests
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: python -m pytest tests/test_provider_integration.py -m integration -q --tb=short
# ──────────────────────────────────────────────────────────────────
# Tier 3: Build & Release (tag push only)
# ──────────────────────────────────────────────────────────────────
@ -138,8 +176,37 @@ jobs:
artifact: zip
runs-on: ${{ matrix.os }}
env:
OUROBOROS_MANAGED_SOURCE_BRANCH: ouroboros-three-layer
OUROBOROS_MANAGED_SOURCE_BRANCH: ouroboros
OUROBOROS_RELEASE_TAG: ${{ github.ref_name }}
# Apple signing secrets at JOB LEVEL with a per-matrix-shard guard.
#
# Step-level `if:` conditions can only read `env.*`, never `secrets.*`
# directly (GitHub Actions rejects the workflow with "Unrecognized
# named-value: 'secrets'"). See docs/DEVELOPMENT.md::"GitHub Actions:
# secrets in step-level if conditions".
#
# The `matrix.os == 'macos-latest' && ... || ''` GHA expression keeps
# the Apple signing/notarization values **scoped to the macOS shard
# only** — Linux and Windows shards (which run `build_linux.sh` and
# `build_windows.ps1` respectively, neither of which needs Apple
# creds) receive empty strings. This avoids exposing the signing
# material to non-macOS build subprocesses where it has no business
# being. When a secret is not configured even on macOS, the value
# is also empty string (not unset), and the gate `env.X != ''`
# evaluates false — the signing/notarization steps skip cleanly.
BUILD_CERTIFICATE_BASE64: ${{ matrix.os == 'macos-latest' && secrets.BUILD_CERTIFICATE_BASE64 || '' }}
P12_PASSWORD: ${{ matrix.os == 'macos-latest' && secrets.P12_PASSWORD || '' }}
KEYCHAIN_PASSWORD: ${{ matrix.os == 'macos-latest' && secrets.KEYCHAIN_PASSWORD || '' }}
APPLE_TEAM_ID: ${{ matrix.os == 'macos-latest' && secrets.APPLE_TEAM_ID || '' }}
APPLE_ID: ${{ matrix.os == 'macos-latest' && secrets.APPLE_ID || '' }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ matrix.os == 'macos-latest' && secrets.APPLE_APP_SPECIFIC_PASSWORD || '' }}
# SIGN_IDENTITY is a forks-friendly override: when a fork configures
# a Developer ID secret whose CN differs from the upstream default
# (e.g. "Developer ID Application: <Other Name> (<OtherTeamID>)"),
# they set `SIGN_IDENTITY` as a repository secret and codesign in
# build.sh picks it up via `${SIGN_IDENTITY:-...}`. Same matrix.os
# guard so Linux/Windows shards never see it.
SIGN_IDENTITY: ${{ matrix.os == 'macos-latest' && secrets.SIGN_IDENTITY || '' }}
steps:
- uses: actions/checkout@v4
with:
@ -184,10 +251,49 @@ jobs:
shell: pwsh
run: .\scripts\download_python_standalone.ps1
# —— macOS build ——
# —— macOS: import signing certificate (only when ALL four signing
# secrets are present at job level — see env: block above)
- name: Import Apple signing certificate
if: matrix.os == 'macos-latest' && env.BUILD_CERTIFICATE_BASE64 != '' && env.P12_PASSWORD != '' && env.KEYCHAIN_PASSWORD != '' && env.APPLE_TEAM_ID != ''
run: |
set -euo pipefail
CERTIFICATE_PATH="$RUNNER_TEMP/build_certificate.p12"
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
# Always remove the .p12 on EXIT, including failure mid-import:
# `set -e` would otherwise abort before the trailing `rm -f` and
# leave the certificate blob on the runner until cleanup. The
# later `Cleanup keychain` step only handles the keychain itself.
trap 'rm -f "$CERTIFICATE_PATH"' EXIT
echo "${BUILD_CERTIFICATE_BASE64}" | base64 --decode > "$CERTIFICATE_PATH"
security create-keychain -p "${KEYCHAIN_PASSWORD}" "$KEYCHAIN_PATH"
security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH"
security unlock-keychain -p "${KEYCHAIN_PASSWORD}" "$KEYCHAIN_PATH"
security import "$CERTIFICATE_PATH" -P "${P12_PASSWORD}" -A -t cert -f pkcs12 -k "$KEYCHAIN_PATH"
security list-keychain -d user -s "$KEYCHAIN_PATH"
security set-key-partition-list -S apple-tool:,apple: -k "${KEYCHAIN_PASSWORD}" "$KEYCHAIN_PATH" >/dev/null
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
# —— macOS build (signed + optionally notarized when secrets are
# present, otherwise unsigned). build.sh reads the same
# env vars from the job-level env block above.
- name: Build macOS app
if: matrix.os == 'macos-latest'
run: OUROBOROS_SIGN=0 bash build.sh
run: |
if [ -n "${BUILD_CERTIFICATE_BASE64:-}" ] && [ -n "${P12_PASSWORD:-}" ] && [ -n "${KEYCHAIN_PASSWORD:-}" ] && [ -n "${APPLE_TEAM_ID:-}" ]; then
echo "Signing certificate detected — building with codesign + (optional) notarization"
bash build.sh
else
echo "No signing secrets — building unsigned (OUROBOROS_SIGN=0)"
OUROBOROS_SIGN=0 bash build.sh
fi
# —— macOS: cleanup keychain (always, even on build failure) so the
# temporary signing material never persists across runs.
- name: Cleanup keychain
if: always() && matrix.os == 'macos-latest' && env.BUILD_CERTIFICATE_BASE64 != ''
run: |
KEYCHAIN_PATH="$RUNNER_TEMP/app-signing.keychain-db"
security delete-keychain "$KEYCHAIN_PATH" || true
# —— Linux build ——
- name: Build Linux binary

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
5.0.0
5.1.0

View file

@ -1,11 +1,14 @@
#!/bin/bash
set -e
SIGN_IDENTITY="Developer ID Application: Ian Mironov (WHY6PAKA5V)"
NOTARYTOOL_PROFILE="ouroboros-notarize"
# Signing identity: env override wins so CI runners (which import a temporary
# Developer ID via `security import` + `security set-key-partition-list`) can
# point at whatever identity matches their imported certificate without
# editing this file. Local dev keeps the historical default identity.
SIGN_IDENTITY="${SIGN_IDENTITY:-Developer ID Application: Ian Mironov (WHY6PAKA5V)}"
ENTITLEMENTS="entitlements.plist"
SIGN_MODE="${OUROBOROS_SIGN:-1}"
MANAGED_SOURCE_BRANCH="${OUROBOROS_MANAGED_SOURCE_BRANCH:-ouroboros-three-layer}"
MANAGED_SOURCE_BRANCH="${OUROBOROS_MANAGED_SOURCE_BRANCH:-ouroboros}"
RELEASE_TAG="v$(tr -d '[:space:]' < VERSION)"
APP_PATH="dist/Ouroboros.app"
@ -125,6 +128,55 @@ if [ "$SIGN_MODE" != "0" ]; then
codesign -s "$SIGN_IDENTITY" --timestamp "$DMG_PATH"
fi
# Optional notarization: only fires when codesign already ran AND the three
# notarytool credentials are present in env. This way unsigned builds skip
# the whole notarization path, and signed-but-unconfigured builds (no Apple
# ID configured for notarization) still ship cleanly — they just need
# right-click → Open on first launch on receiver machines.
#
# A single enum tracks the outcome so the final summary cascade can
# distinguish all four cases without contradiction:
# * success — notarytool submit AND stapler staple both succeeded
# * staple_failed — notarytool submit OK; stapler failed (Gatekeeper
# fetches the ticket online; DMG is genuinely notarized)
# * submit_failed — notarytool submit failed (DMG is signed only)
# * unconfigured — Apple credentials not set (signed-only OR unsigned)
NOTARIZE_OUTCOME="unconfigured"
if [ "$SIGN_MODE" != "0" ] \
&& [ -n "${APPLE_ID:-}" ] \
&& [ -n "${APPLE_TEAM_ID:-}" ] \
&& [ -n "${APPLE_APP_SPECIFIC_PASSWORD:-}" ]; then
echo ""
echo "=== Notarizing DMG (Apple ID: $APPLE_ID) ==="
# `--wait` blocks until Apple finishes the notarization scan so the
# subsequent `xcrun stapler staple` always operates on a finalized ticket.
# A submit failure is treated as a soft warning (DMG is signed; release
# ships with a clear log line) rather than a hard build abort, so an
# Apple-side outage / wrong-credential typo never silently drops the
# macOS artifact from the GitHub Release.
if xcrun notarytool submit "$DMG_PATH" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAM_ID" \
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
--wait; then
echo "--- Stapling notarization ticket ---"
# Stapler hits Apple's CDN separately and can fail transiently after
# a successful notarytool submission. Treat that as a soft warning
# too: the DMG is still signed + notarized, Gatekeeper will fetch
# the ticket online on first launch (slower but functional). Without
# this guard `set -e` would abort the script.
if xcrun stapler staple "$DMG_PATH"; then
NOTARIZE_OUTCOME="success"
else
NOTARIZE_OUTCOME="staple_failed"
echo "WARNING: stapler staple failed — DMG is notarized but ticket not embedded; receivers may briefly need right-click → Open until Apple's ticket propagates."
fi
else
NOTARIZE_OUTCOME="submit_failed"
echo "WARNING: notarytool submit failed — DMG is signed but not notarized; verify APPLE_ID / APPLE_TEAM_ID / APPLE_APP_SPECIFIC_PASSWORD are correct or check the notarytool log above."
fi
fi
echo ""
echo "=== Done ==="
if [ "$SIGN_MODE" != "0" ]; then
@ -134,4 +186,27 @@ else
echo "Unsigned app: $APP_PATH"
echo "Unsigned DMG: $DMG_PATH"
fi
echo "(Not notarized — users need right-click → Open on first launch)"
case "$NOTARIZE_OUTCOME" in
success)
echo "(Notarized + stapled — no right-click → Open required on first launch)"
;;
staple_failed)
echo "(Notarized but ticket not stapled — Gatekeeper will fetch the ticket online; receivers need internet on first launch)"
;;
submit_failed)
echo "(Signed but notarytool submit failed — DMG was not accepted by Apple; check the WARNING above for details)"
;;
unconfigured)
if [ "$SIGN_MODE" != "0" ]; then
echo "(Signed but not notarized — set APPLE_ID / APPLE_TEAM_ID / APPLE_APP_SPECIFIC_PASSWORD to enable notarization)"
else
echo "(Not notarized — users need right-click → Open on first launch)"
fi
;;
*)
# Defensive default: a future enum value added to NOTARIZE_OUTCOME
# without a matching arm would otherwise silently print no summary
# line. Surface it loudly so the bug is easy to find.
echo "(Unknown notarization outcome: '$NOTARIZE_OUTCOME' — please report; likely a missing case arm in build.sh)"
;;
esac

View file

@ -3,7 +3,7 @@ set -e
VERSION=$(tr -d '[:space:]' < VERSION)
ARCHIVE_NAME="Ouroboros-${VERSION}-linux-$(uname -m).tar.gz"
MANAGED_SOURCE_BRANCH="${OUROBOROS_MANAGED_SOURCE_BRANCH:-ouroboros-three-layer}"
MANAGED_SOURCE_BRANCH="${OUROBOROS_MANAGED_SOURCE_BRANCH:-ouroboros}"
RELEASE_TAG="v${VERSION}"
PYTHON_CMD="${PYTHON_CMD:-python3}"

View file

@ -5,7 +5,7 @@ $ErrorActionPreference = "Stop"
$Version = (Get-Content VERSION).Trim()
$ArchiveName = "Ouroboros-${Version}-windows-x64.zip"
$ManagedSourceBranch = if ($env:OUROBOROS_MANAGED_SOURCE_BRANCH) { $env:OUROBOROS_MANAGED_SOURCE_BRANCH } else { "ouroboros-three-layer" }
$ManagedSourceBranch = if ($env:OUROBOROS_MANAGED_SOURCE_BRANCH) { $env:OUROBOROS_MANAGED_SOURCE_BRANCH } else { "ouroboros" }
$ReleaseTag = "v$Version"
Write-Host "=== Building Ouroboros for Windows (v${Version}) ==="

View file

@ -1,4 +1,4 @@
# Ouroboros v5.0.0 — Architecture & Reference
# Ouroboros v5.1.0 — Architecture & Reference
This document describes every component, page, button, API endpoint, and data flow.
It is the single source of truth for how the system works. Keep it updated.
@ -109,7 +109,7 @@ server.py (Starlette+uvicorn) ← HTTP + WebSocket on configurable host:port (de
└── platform_layer.py ← Cross-platform process/path/locking helpers
# Build & CI (not part of runtime)
.github/workflows/ci.yml ← Three-tier CI (quick/full/release)
.github/workflows/ci.yml ← Four-tier CI (quick / full / integration / build+release)
build.sh ← macOS build (PyInstaller → .dmg)
build_linux.sh ← Linux build (PyInstaller → .tar.gz)
build_windows.ps1 ← Windows build (PyInstaller → .zip)
@ -316,7 +316,8 @@ Navigation is a left sidebar with 8 pages (Chat, Files, Logs, Costs, Evolution,
Driven by WebSocket connection state, typing events, and live task state.
- **Header controls**: compact buttons for `/evolve`, `/bg`, `/review`, `/restart`, `/panic` — the canonical location for runtime controls. The chat header is a floating transparent overlay (`position: absolute`, gradient fade with a non-zero opacity floor plus backdrop blur) so messages scroll beneath it on desktop without text reading through the header labels. On narrow viewports (`@media (max-width: 640px)`) `.chat-page-header` switches to `position: static` with a transparent background so it claims its own vertical space — a wrapped row of action buttons never hides the first chat message under the gradient; the `#chat-messages` top padding is correspondingly reduced from `56px` (absolute-header clearance) to `12px`.
- **Budget pill**: compact amber pill in the header showing `$spent / $limit` with a mini progress bar, updated from `/api/state` polling every 3 seconds.
- **Message input**: absolute-positioned frosted-glass overlay anchored to the bottom of the chat page (`position: absolute; bottom: 0`). Contains a paperclip attachment button (positioned as an absolute overlay inside the textarea on the left; opens a file picker; selected file is staged locally in JS memory and shown as a removable filename preview badge — the file is uploaded to `data/uploads/` only when Send/Enter is triggered; if the WebSocket is offline at send time, upload is blocked with an error (no upload happens when disconnected, preventing orphan files). If the WebSocket drops after upload completes but before message delivery, the queued message references a durable server-side file that persists until explicitly deleted), a textarea (grows up to 120px; `padding-left: 42px` and `padding-right: 76px` leave space for both overlay controls), and a **send group** (`.chat-send-group`) positioned as an absolute overlay inside the textarea on the right, vertically centered inside the textarea (`top: 50%; transform: translateY(-50%)`). The paperclip attachment button is similarly centered (`top: 50%; transform: translateY(-50%)`), so both controls stay at the visual center of the textarea regardless of whether it is single-line or expanded to its max height. The send group contains a Send/Plan button (`.chat-send-inline`) and a chevron button (`.chat-send-chevron`) with a subtle divider. The send group has a **two-click send model**: the chevron opens a glassmorphism dropdown (`.chat-send-dropdown`) with **Send** and **Plan** items that *switch the active send mode* — they do NOT immediately send. The main button label, colour, and chevron tint reflect the active mode: crimson for Send (default), amber (`var(--amber)`) for Plan. Once the mode is set, clicking the main button or pressing Enter sends in that mode. Mode state is stored as `data-send-mode` on `.chat-send-group` (DOM-backed, CSS-readable single source of truth); `setSendMode(mode)` synchronises button text, title, and `data-mode-active` markers on dropdown items. The chevron is always visible (tappable on touchscreens). The dropdown closes on outside click, item selection, or Escape key. `sendMessage(planMode)` is the shared send function; both the sendBtn click listener and the Enter keydown handler derive `planMode` from `sendGroup.dataset.sendMode === 'plan'` using explicit arrow functions to avoid `MouseEvent` truthy-arg bug. Slash commands bypass the plan prefix regardless of mode. The `#chat-input` textarea has `backdrop-filter: blur(16px)` + semi-transparent background (frosted glass). The `#chat-input-area` wrapper uses a gradient fade overlay with a non-zero top opacity, and the attachment badge has its own blurred glass backing, so message bubbles still scroll underneath without filename/text double exposure. `#chat-messages` `padding-bottom` is set dynamically via `updateMessagesPadding()` in chat.js (real overlay height + 16px buffer), called on page connect, textarea resize, and attachment preview toggle. The CSS default is `84px` (covers min-height state); JS adjusts up to ~160px when the textarea is fully expanded. Shift+Enter for newline, Enter to send.
- **Message input**: absolute-positioned frosted-glass overlay anchored to the bottom of the chat page (`position: absolute; bottom: 0`). Contains a paperclip attachment button (positioned as an absolute overlay inside the textarea on the left; opens a file picker; selected file is staged locally in JS memory and shown as a removable filename preview badge — the file is uploaded to `data/uploads/` only when Send/Enter is triggered; if the WebSocket is offline at send time, upload is blocked with an error (no upload happens when disconnected, preventing orphan files). If the WebSocket drops after upload completes but before message delivery, the queued message references a durable server-side file that persists until explicitly deleted), a textarea (grows up to 120px; `padding-left: 42px` and `padding-right: 76px` leave space for both overlay controls), and a **send group** (`.chat-send-group`) positioned as an absolute overlay inside the textarea on the right, vertically centered inside the textarea (`top: 50%; transform: translateY(-50%)`). The paperclip attachment button is similarly centered (`top: 50%; transform: translateY(-50%)`), so both controls stay at the visual center of the textarea regardless of whether it is single-line or expanded to its max height. The send group contains a Send/Plan button (`.chat-send-inline`) and a chevron button (`.chat-send-chevron`) with a subtle divider. The send group has a **two-click send model**: the chevron opens a glassmorphism dropdown (`.chat-send-dropdown`) with **Send** and **Plan** items that *switch the active send mode* — they do NOT immediately send. The main button label, colour, and chevron tint reflect the active mode: crimson for Send (default), amber (`var(--amber)`) for Plan. Once the mode is set, clicking the main button or pressing Enter sends in that mode. Mode state is stored as `data-send-mode` on `.chat-send-group` (DOM-backed, CSS-readable single source of truth); `setSendMode(mode)` synchronises button text, title, and `data-mode-active` markers on dropdown items. The chevron is always visible (tappable on touchscreens). The dropdown closes on outside click, item selection, or Escape key. `sendMessage(planMode)` is the shared send function; both the sendBtn click listener and the Enter keydown handler derive `planMode` from `sendGroup.dataset.sendMode === 'plan'` using explicit arrow functions to avoid `MouseEvent` truthy-arg bug. Slash commands bypass the plan prefix regardless of mode. The `#chat-input` textarea has `backdrop-filter: blur(16px)` + semi-transparent background (frosted glass) and explicitly disables browser-level autocorrect / autocapitalize / spellcheck (`autocorrect="off" autocapitalize="off" spellcheck="false"`) so code, identifiers, and slash-commands are not silently mangled. Bottom scroll-under fade is rendered by a **dedicated sibling layer** `<div class="chat-bottom-fade" aria-hidden="true">` (`position: absolute; bottom: 0; pointer-events: none; z-index: 4`) so it always sits **below** `#chat-input-area` (`z-index: 5`) and never optically swallows the textarea or the attachment badge — earlier versions painted the gradient as `#chat-input-area`'s own background, which made the lower part of the textarea visually dissolve into the dense end of the gradient. `#chat-messages` `padding-bottom` is set dynamically via `updateMessagesPadding()` in chat.js (real overlay height + 16px buffer), called on page connect, textarea resize, and attachment preview toggle. The CSS default is `84px` (covers min-height state); JS adjusts up to ~160px when the textarea is fully expanded. Shift+Enter for newline, Enter to send.
- **Clipboard image paste**: `#chat-input` registers a `paste` listener that scans `e.clipboardData.items` for `image/*` MIME types, calls `getAsFile()` on the first match, wraps the blob as a `File` named `clipboard-<unix-ts>.<ext>` (extension derived from the MIME), and stages it through the **same** `pendingAttachment` slot used by the paperclip button (the badge / removal UI / upload-on-Send path are all reused — no inline upload happens until Send/Enter, mirroring the paperclip semantics including the offline-WS guard). Non-image clipboard payloads fall through to native paste (text / formatted text). When an image is staged this way, `e.preventDefault()` suppresses the browser's default paste so a giant base64 string is not also inserted into the textarea.
- **Input recall**: ArrowUp / ArrowDown cycles through recent submitted messages without leaving the textarea. On the **first** successful `syncHistory` call of this page lifetime, it seeds `inputHistory` from server-side user messages (including Telegram and other-session messages that never went through the local `rememberInput()` path). Merge strategy: server messages (older) prepend before local session entries (newer); deduped from the end so most-recent entries always win; capped at 50 via `slice(-50)`. `PLAN_PREFIX` preambles are stripped before storage. Seeding is gated on a dedicated one-shot `inputHistorySeededFromServer` flag (not `historyLoaded`) so it fires on the first successful server sync even when the initial fetch failed and `historyLoaded` was already set true by the sessionStorage-fallback bootstrap path. Subsequent WebSocket reconnects deliberately do NOT re-seed (that would reset `inputHistoryIndex` mid-scrub), so Telegram/other-session messages that arrive while this tab stays open only surface in recall after the next full page reload.
- **Messages**: user bubbles (right, steel-blue-tinted), assistant bubbles (left, crimson), and system-summary bubbles (left, amber). Non-user bubbles render markdown. Live task card uses crimson accent glass matching the assistant palette.
- **Multi-user visibility**: user messages are now session-aware. The current browser session stays labeled as `You`; other Web UI sessions render as `WebUI (<session>)`; Telegram-origin messages render with their Telegram sender label.
@ -1586,19 +1587,70 @@ Uncommitted changes are rescued to `~/Ouroboros/data/archive/rescue/` before res
## 8.1 CI/CD Pipeline (`.github/workflows/ci.yml`)
Three-tier GitHub Actions workflow:
Four-tier GitHub Actions workflow:
| Tier | Trigger | What runs | Time |
|------|---------|-----------|------|
| Quick | Push to `ouroboros` or `ouroboros-three-layer` (code paths only) | Ubuntu-only: `pytest` | ~1 min |
| Quick | Push to `ouroboros` (code paths only) | Ubuntu-only: `pytest` | ~1 min |
| Full | Push to `ouroboros-stable`, manual (`workflow_dispatch`), or tag `v*` | Matrix: Ubuntu + Windows + macOS: `pytest` | ~5 min |
| Build | Tag `v*` (after full-test passes) | Matrix: PyInstaller build → `.dmg` / `.tar.gz` / `.zip` + GitHub Release | ~15 min |
| Integration | Push to `main`, `ouroboros`, or `ouroboros-stable`, manual (`workflow_dispatch`), or tag `v*` | Ubuntu-only: `pytest tests/test_provider_integration.py -m integration` against real OpenRouter / OpenAI / Anthropic keys (skipped per-provider when its key is unset) | ~2 min |
| Build + Release | Tag `v*` (after full-test passes) | Matrix: PyInstaller build → `.dmg` / `.tar.gz` / `.zip` (macOS optionally codesigned + notarized when Apple secrets are configured) + GitHub Release | ~15 min |
Path filters for branch pushes: `ouroboros/**`, `supervisor/**`, `server.py`, `launcher.py`,
`tests/**`, `web/**`, `requirements.txt`, `pyproject.toml`, `.github/workflows/**`, `build.sh`,
`build_linux.sh`, `build_windows.ps1`, `Dockerfile`, `scripts/**`, `VERSION`, `README.md`.
Tag pushes (`v*`) always fire regardless of paths.
**macOS code signing & notarization (Build tier).** When the build job has access to
`BUILD_CERTIFICATE_BASE64`, `P12_PASSWORD`, `KEYCHAIN_PASSWORD`, and `APPLE_TEAM_ID` as
GitHub Actions repository secrets, the build job creates a temporary keychain, imports
the Developer ID certificate, and runs `bash build.sh` (which then signs the `.app` and
the `.dmg`); when `APPLE_ID` and `APPLE_APP_SPECIFIC_PASSWORD` are also present,
`build.sh` additionally runs `xcrun notarytool submit ... --wait` followed by
`xcrun stapler staple` to staple the notarization ticket to the DMG. A transient
stapler failure (Apple CDN propagation lag) after a successful notarytool submission
is treated as a soft warning — the DMG is genuinely notarized and Gatekeeper validates
it online; without that guard `set -e` would abort the build and silently drop the
macOS artifact from the release. A `notarytool submit` failure (Apple-side outage,
wrong credential) is handled the same way — the DMG ships signed-but-not-notarized
with a clear `WARNING` log line rather than aborting, so an Apple outage never
silently removes the macOS artifact from the GitHub Release. The four observable
notarization outcomes (`success` / `staple_failed` / `submit_failed` / `unconfigured`)
each render a distinct summary line at the end of the build, plus a defensive
`Unknown notarization outcome` arm so future enum drift is loud rather than silent. With no Apple secrets configured, the macOS build
falls back to the unsigned path (`OUROBOROS_SIGN=0 bash build.sh`) — users still need
right-click → **Open** on first launch. Forks enable signing by configuring all four
required secrets above; `SIGN_IDENTITY` is an additional optional secret for forks
whose Developer ID differs from the upstream default. **The signing secrets are mapped
at the build job's `env:` block, not at step level, because GitHub Actions rejects
`secrets.*` references inside step-level `if:` expressions ("Unrecognized named-value:
'secrets'") — step `if:` conditions read `env.*` instead.** Each mapping is additionally
guarded by `${{ matrix.os == 'macos-latest' && secrets.X || '' }}` so the Apple
credentials are scoped to the macOS matrix shard only — Linux and Windows sibling
shards (which run `build_linux.sh` / `build_windows.ps1`, neither of which needs Apple
creds) receive empty strings, so the signing material is never exposed to non-macOS
build subprocesses. A `Cleanup keychain` step with
`if: always() && matrix.os == 'macos-latest' && env.BUILD_CERTIFICATE_BASE64 != ''`
deletes the temporary keychain regardless of build outcome — the `matrix.os` gate keeps
the bash-only `security` invocation off Linux/Windows shards, and the env guard skips
when no keychain was ever created. Signing material never persists on the runner. See
`docs/DEVELOPMENT.md::GitHub Actions: secrets in step-level if conditions` for the
rationale.
**Integration tier credentials.** The integration job consumes
`OPENROUTER_API_KEY`, `OPENAI_API_KEY`, and `ANTHROPIC_API_KEY` from repository secrets
and exposes them as `env:` to a single `pytest` invocation; tests gated on missing keys
skip cleanly via per-test `@pytest.mark.skipif(not os.environ.get(KEY))` decorators
inside the test file. Locally the same tests are excluded by default through the
`integration` pytest marker plus `addopts = "-m 'not integration'"` in `pyproject.toml`,
so contributors do not accidentally burn provider tokens on every `pytest` run. Opt in
with `pytest -m integration` (the whole file) or
`pytest -m integration tests/test_provider_integration.py::test_openrouter_basic_chat`
(specific test). A bare `pytest tests/test_provider_integration.py::test_X` without
`-m integration` is **deselected** by `addopts -m 'not integration'` and exits 5
("no tests collected"); use the explicit `-m integration` form, or override the
default with `pytest -o addopts='' tests/test_provider_integration.py::test_X`.
### Build scripts
| Script | Platform | Output |

View file

@ -340,3 +340,86 @@ Existing classes (`.stat-card`, `.page-header`, `.about-*`, `.costs-*`) cover co
Add new classes to `web/style.css` when needed.
Before staging any `web/modules/*.js` file: `grep -n "\.style\." web/modules/*.js`
and fix any hits.
---
## Build & CI
### GitHub Actions: secrets in step-level `if:` conditions
GitHub Actions **rejects** `secrets.*` references inside step-level `if:`
expressions with `Unrecognized named-value: 'secrets'`. The workflow file
fails to parse and the job never runs. Step-level `env:` blocks **are also
not visible to that step's own `if:`** — only job-level `env:` is.
When a step needs to gate on whether a secret is configured, **map the
secret into the build job's `env:` block, then reference `env.*` in the
step `if:`**. The step itself can then either use the env var directly
(it inherits from the job) or assume it is present.
```yaml
jobs:
build:
runs-on: macos-latest
env:
# job-level: visible to step-level `if:` via env.*
BUILD_CERTIFICATE_BASE64: ${{ secrets.BUILD_CERTIFICATE_BASE64 }}
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
steps:
- name: Import Apple signing certificate
# ✅ env.* — visible inside step-level if
if: env.BUILD_CERTIFICATE_BASE64 != '' && env.P12_PASSWORD != ''
run: |
echo "${BUILD_CERTIFICATE_BASE64}" | base64 -d > cert.p12
security import cert.p12 -P "${P12_PASSWORD}" ...
- name: Cleanup keychain
if: always() && env.BUILD_CERTIFICATE_BASE64 != ''
run: security delete-keychain ...
```
```yaml
# ❌ WRONG — workflow fails to parse
- name: Bad
if: secrets.BUILD_CERTIFICATE_BASE64 != '' # parse error
env: # not visible to this step's if:
P12_PASSWORD: ${{ secrets.P12_PASSWORD }}
```
This pattern is enforced by `tests/test_build_scripts.py::TestMacOSSigning::
test_ci_uses_env_context_for_condition`, which parses every `if:` block in
`.github/workflows/ci.yml` (including multi-line continuations) and asserts
no occurrence of `secrets.` ever appears inside one.
### Apple signing & notarization (macOS Build job)
When `BUILD_CERTIFICATE_BASE64`, `P12_PASSWORD`, `KEYCHAIN_PASSWORD`, and
`APPLE_TEAM_ID` are configured as repository secrets, the macOS build job
imports the Developer ID certificate into a temporary keychain and runs
`bash build.sh` — `build.sh` then signs the `.app` and the `.dmg` using
the env-overridable `SIGN_IDENTITY`. Each Apple secret is mapped at the
build job's `env:` block with a `${{ matrix.os == 'macos-latest' && secrets.X || '' }}`
guard so the Apple credentials reach the macOS matrix shard only; Linux
and Windows sibling shards (running `build_linux.sh` / `build_windows.ps1`,
neither of which needs Apple creds) receive empty strings. When `APPLE_ID` and
`APPLE_APP_SPECIFIC_PASSWORD` are also present, `build.sh` runs
`xcrun notarytool submit ... --wait` followed by `xcrun stapler staple` to
attach the notarization ticket; otherwise the entire notarization block is
skipped and the DMG ships **signed but not notarized** (users still need
right-click → **Open** on first launch). The stapler call is wrapped in
its own guard so a transient stapler failure after a successful notarytool
submission becomes a soft warning rather than a hard build failure (the
DMG is genuinely notarized — Gatekeeper just fetches the ticket online on
first launch instead of from the embedded staple). The `notarytool submit`
call is wrapped the same way: an Apple-side outage / wrong-credential typo
prints a `WARNING` and lets the DMG ship signed-but-not-notarized, instead
of aborting the build under `set -e` and silently dropping the macOS
artifact from the release. A single `NOTARIZE_OUTCOME` enum (`success` /
`staple_failed` / `submit_failed` / `unconfigured`) drives the build's
final summary line so the WARN message and the summary always agree on
the actual artifact state, plus a defensive `*)` arm so any future enum
drift is loud. The `Cleanup keychain`
step runs with `if: always() && matrix.os == 'macos-latest' && env.BUILD_CERTIFICATE_BASE64 != ''`
— `always()` ensures cleanup fires on build failures too, the `matrix.os`
gate keeps the bash-only `security` invocation off Linux/Windows shards,
and the env guard skips when no keychain was created (no signing secrets).
Signing material never persists across runs.

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "ouroboros"
version = "5.0.0"
version = "5.1.0"
description = "Self-creating AI agent with constitution, background consciousness, and persistent identity"
readme = "README.md"
license = {text = "MIT"}
@ -64,7 +64,13 @@ all = ["claude-agent-sdk>=0.1.60", "a2a-sdk[http-server]>=0.3.20", "playwright",
testpaths = ["tests"]
python_files = ["test_*.py"]
python_functions = ["test_*"]
addopts = "-q --tb=short"
# Default local runs exclude the `integration` marker so contributors do not
# accidentally burn provider tokens on every `pytest`. CI Tier 2.5 opts in
# explicitly with `pytest -m integration` (the CLI -m overrides addopts -m).
addopts = "-q --tb=short -m 'not integration'"
markers = [
"integration: requires real provider API keys (OPENROUTER_API_KEY / OPENAI_API_KEY / ANTHROPIC_API_KEY); excluded from default local runs, opt in via `pytest -m integration`",
]
[tool.setuptools]
py-modules = ["server"]

View file

@ -56,7 +56,7 @@ def _make_repo(tmp_path):
_run(["git", "init"], cwd=repo)
_run(["git", "config", "user.name", "Test"], cwd=repo)
_run(["git", "config", "user.email", "test@example.com"], cwd=repo)
_run(["git", "checkout", "-b", "ouroboros-three-layer"], cwd=repo)
_run(["git", "checkout", "-b", "ouroboros"], cwd=repo)
_run(["git", "remote", "add", "origin", "git@github.com:joi-lab/ouroboros-desktop.git"], cwd=repo)
(repo / "VERSION").write_text("4.50.0-rc.2\n", encoding="utf-8")
(repo / "server.py").write_text("print('ok')\n", encoding="utf-8")
@ -80,7 +80,7 @@ def test_build_repo_bundle_accepts_explicit_source_branch_on_detached_head(tmp_p
"--repo-root",
str(repo),
"--source-branch",
"ouroboros-three-layer",
"ouroboros",
"--output-bundle",
str(bundle),
"--output-manifest",
@ -94,7 +94,7 @@ def test_build_repo_bundle_accepts_explicit_source_branch_on_detached_head(tmp_p
assert payload["source_sha"] == sha
assert payload["release_tag"] == "v4.50.0-rc.2"
assert payload["bundle_sha256"]
assert payload["managed_remote_branch"] == "ouroboros-three-layer"
assert payload["managed_remote_branch"] == "ouroboros"
assert payload["managed_remote_url"] == "https://github.com/joi-lab/ouroboros-desktop.git"
@ -115,7 +115,7 @@ def test_build_repo_bundle_uses_checked_out_head_not_branch_tip(tmp_path):
"--repo-root",
str(repo),
"--source-branch",
"ouroboros-three-layer",
"ouroboros",
"--output-bundle",
str(bundle),
"--output-manifest",
@ -142,7 +142,7 @@ def test_build_repo_bundle_refuses_dirty_worktree(tmp_path):
"--repo-root",
str(repo),
"--source-branch",
"ouroboros-three-layer",
"ouroboros",
"--output-bundle",
str(bundle),
"--output-manifest",
@ -168,7 +168,7 @@ def test_build_repo_bundle_rejects_reserved_origin_remote_name(tmp_path):
"--repo-root",
str(repo),
"--source-branch",
"ouroboros-three-layer",
"ouroboros",
"--managed-remote-name",
"origin",
"--output-bundle",
@ -197,7 +197,7 @@ def test_build_repo_bundle_preserves_https_remote_ports(tmp_path):
"--repo-root",
str(repo),
"--source-branch",
"ouroboros-three-layer",
"ouroboros",
"--output-bundle",
str(bundle),
"--output-manifest",
@ -250,7 +250,7 @@ def test_build_repo_bundle_rejects_release_tag_mismatch(tmp_path):
"--repo-root",
str(repo),
"--source-branch",
"ouroboros-three-layer",
"ouroboros",
"--output-bundle",
str(bundle),
"--output-manifest",
@ -280,7 +280,7 @@ def test_build_repo_bundle_requires_release_tag(tmp_path):
"--repo-root",
str(repo),
"--source-branch",
"ouroboros-three-layer",
"ouroboros",
"--output-bundle",
str(bundle),
"--output-manifest",
@ -326,7 +326,7 @@ def test_build_repo_bundle_rejects_head_outside_source_branch(tmp_path):
"--repo-root",
str(repo),
"--source-branch",
"ouroboros-three-layer",
"ouroboros",
"--output-bundle",
str(bundle),
"--output-manifest",
@ -362,7 +362,7 @@ def test_build_repo_bundle_rejects_lightweight_tag(tmp_path):
"--repo-root",
str(repo),
"--source-branch",
"ouroboros-three-layer",
"ouroboros",
"--output-bundle",
str(bundle),
"--output-manifest",
@ -434,7 +434,7 @@ def test_build_repo_bundle_rejects_env_tag_not_on_head(tmp_path):
"--repo-root",
str(repo),
"--source-branch",
"ouroboros-three-layer",
"ouroboros",
"--output-bundle",
str(bundle),
"--output-manifest",

View file

@ -278,3 +278,270 @@ class TestDockerfile:
f"Found {len(playwright_invocations)} playwright invocation(s) at positions: "
f"{playwright_invocations}"
)
# ---------------------------------------------------------------------------
# .github/workflows/ci.yml + build.sh — macOS code signing & notarization
# ---------------------------------------------------------------------------
class TestMacOSSigning:
"""The CI build job and build.sh together implement optional macOS code
signing and notarization. Seven contracts are pinned here to prevent
regression of the GitHub Actions `secrets.*`-in-step-`if:` pitfall, the
build-script env override / optional-notarytool gate, the keychain
cleanup guard, and the stapler-failure-as-soft-warning behaviour.
See docs/DEVELOPMENT.md::"GitHub Actions: secrets in step-level if
conditions" for the rationale.
"""
_CI_PATH = ".github/workflows/ci.yml"
_SIGNING_SECRETS = (
"BUILD_CERTIFICATE_BASE64",
"P12_PASSWORD",
"KEYCHAIN_PASSWORD",
"APPLE_TEAM_ID",
)
_NOTARIZE_SECRETS = (
"APPLE_ID",
"APPLE_APP_SPECIFIC_PASSWORD",
)
@staticmethod
def _build_job_header(src: str) -> str:
"""Slice the build job header (everything between ` build:` and the
first ` steps:` underneath it) so signing-secret env mappings can
be located without false positives from later step-level env blocks."""
build_idx = src.find("\n build:\n")
assert build_idx != -1, "build job not found in ci.yml"
steps_idx = src.find("\n steps:", build_idx)
assert steps_idx != -1, "build.steps: not found in ci.yml"
return src[build_idx:steps_idx]
@staticmethod
def _iter_step_if_blocks(src: str):
"""Yield every `if:` expression in the workflow as a flat string.
Catches BOTH step-level and job-level `if:` blocks (the
`Unrecognized named-value: 'secrets'` rejection applies at every
level, so checking job-level too is strictly more conservative).
Heuristic: collect lines starting from `if:` until the next YAML
key starts (a line whose first non-space char is `-` or whose
stripped form contains a `:`). Known limitation: a future `if:`
whose continuation lines legitimately contain `:` (string literals,
nested expressions) would be split prematurely; the current ci.yml
has no such case. If that pattern is added, switch to a real YAML
parser walking each step's `if` field.
"""
lines = src.splitlines()
in_if = False
block: list[str] = []
for line in lines:
stripped = line.strip()
if stripped.startswith("if:"):
if in_if and block:
yield " ".join(block)
in_if = True
block = [stripped]
continue
if in_if:
# Continuation: indented, not a new YAML key, not a step start.
if stripped and not stripped.startswith("- ") and ":" not in stripped:
block.append(stripped)
else:
yield " ".join(block)
in_if = False
block = []
if in_if and block:
yield " ".join(block)
def test_ci_signing_secrets_at_job_level(self):
"""All four required signing secrets MUST be mapped at the build job's
env: block (not at step level), so step-level `if:` conditions can
read `env.*`. Step-level env blocks are NOT visible to that step's
own `if:` — only job-level env is.
Each mapping must ALSO be guarded by `matrix.os == 'macos-latest'`
so the Apple credentials are scoped to the macOS matrix shard only;
Linux and Windows sibling shards receive empty strings. This avoids
exposing the signing material to `build_linux.sh` / `build_windows.ps1`
subprocesses that have no use for it.
"""
src = _read(self._CI_PATH)
header = self._build_job_header(src)
# Required form (per secret): `<NAME>: ${{ matrix.os == 'macos-latest' && secrets.<NAME> || '' }}`
for secret in self._SIGNING_SECRETS:
expected = (
f"{secret}: ${{{{ matrix.os == 'macos-latest' "
f"&& secrets.{secret} || '' }}}}"
)
assert expected in header, (
f"build job env: must map {secret} at job level with a "
f"matrix.os == 'macos-latest' guard so non-macOS shards "
f"receive empty strings. Expected line: {expected!r}"
)
# Optional notarization secrets must also be mapped at job level
# (so build.sh inherits them as env vars when it runs), with the
# same matrix-shard guard.
for secret in self._NOTARIZE_SECRETS:
expected = (
f"{secret}: ${{{{ matrix.os == 'macos-latest' "
f"&& secrets.{secret} || '' }}}}"
)
assert expected in header, (
f"build job env: must also map {secret} (with matrix.os "
f"guard) so build.sh can run `xcrun notarytool` when it is "
f"configured. Expected line: {expected!r}"
)
def test_ci_uses_env_context_for_condition(self):
"""No `if:` expression in ci.yml (step-level OR job-level) may
reference `secrets.*`.
GitHub Actions rejects `secrets.*` in `if:` with
`Unrecognized named-value: 'secrets'`. Always use `env.*` instead
(see the job-level env block test above). The parser used here
catches both step-level and job-level `if:` blocks deliberately —
the rejection applies at every level, so a job-level violation
would also break the workflow.
"""
src = _read(self._CI_PATH)
offending = [
block for block in self._iter_step_if_blocks(src)
if "secrets." in block
]
assert not offending, (
"secrets.* must not appear in any step-level if-condition "
"(promote to job-level env: and reference env.* instead). "
f"Offenders: {offending}"
)
def test_ci_import_gates_on_full_secret_set(self):
"""The Import-Apple-signing-certificate step MUST gate on ALL four
required signing secrets via env.*, not just the certificate."""
src = _read(self._CI_PATH)
import_idx = src.find("Import Apple signing certificate")
assert import_idx != -1, (
"Apple signing-certificate Import step not found in ci.yml — "
"the macOS signing path is missing"
)
# Take a generous slice around the Import step's `if:` line.
region = src[import_idx:import_idx + 800]
for env_var in self._SIGNING_SECRETS:
assert f"env.{env_var}" in region, (
f"Import step if-condition must gate on env.{env_var} to "
f"prevent partial-secret runs from importing nothing"
)
def test_ci_cleanup_keychain_step_present(self):
"""A `Cleanup keychain` step must run with `if: always() &&
matrix.os == 'macos-latest' && env.BUILD_CERTIFICATE_BASE64 != ''`
so signing material never persists across runs even when the build
itself fails, and the bash-only `security` invocation never fires
on Linux/Windows shards."""
src = _read(self._CI_PATH)
# Match the actual STEP definition (`- name: Cleanup keychain`), not
# any prose mentioning the step elsewhere in the workflow file (e.g.
# an explanatory comment in the Import step that references the later
# Cleanup step would match a bare substring search). The `- name:`
# anchor pins the assertion to the real step header.
cleanup_anchor = "- name: Cleanup keychain"
assert cleanup_anchor in src, (
"ci.yml must include a `- name: Cleanup keychain` step that "
"deletes the temporary signing keychain after every macOS build"
)
cleanup_idx = src.find(cleanup_anchor)
cleanup_region = src[cleanup_idx:cleanup_idx + 500]
assert "always()" in cleanup_region, (
"Cleanup keychain must run with `if: always()` so it fires on "
"build failures too"
)
assert "matrix.os == 'macos-latest'" in cleanup_region, (
"Cleanup keychain must gate on matrix.os == 'macos-latest' so "
"the bash-only `security delete-keychain` invocation does not "
"fire on Linux/Windows shards (where the secret env var would "
"still be set as job-level env)"
)
assert "env.BUILD_CERTIFICATE_BASE64 != ''" in cleanup_region, (
"Cleanup keychain must gate on env.BUILD_CERTIFICATE_BASE64 so "
"it does not try to delete a keychain that was never created"
)
def test_build_sh_signing_identity_env_override(self):
"""build.sh must allow the signing identity to be overridden via env
(CI runners import a temporary Developer ID and need to point at
whatever identity matches their imported certificate)."""
src = _read("build.sh")
# Accept either ${SIGN_IDENTITY:-...} or ${SIGN_IDENTITY-...}
# (POSIX parameter expansion). We require the explicit `:-` form
# so an empty-string env var still falls back to the default.
assert re.search(
r'SIGN_IDENTITY=\s*"\$\{SIGN_IDENTITY:-[^"]+"',
src,
), (
"build.sh must set SIGN_IDENTITY=\"${SIGN_IDENTITY:-...}\" "
"so the env var wins when present and the default kicks in "
"when it is unset or empty"
)
def test_build_sh_notarization_optional(self):
"""build.sh must include an optional notarization block guarded on
APPLE_ID + APPLE_TEAM_ID + APPLE_APP_SPECIFIC_PASSWORD, calling
`xcrun notarytool submit` followed by `xcrun stapler staple`."""
src = _read("build.sh")
assert "xcrun notarytool submit" in src, (
"build.sh must call `xcrun notarytool submit` to upload the "
"DMG for Apple notarization when credentials are configured"
)
assert "xcrun stapler staple" in src, (
"build.sh must call `xcrun stapler staple` after a successful "
"notarytool submission so the ticket is attached to the DMG"
)
# The notarization block must be guarded on the three notarytool
# credential env vars, otherwise builds without an Apple ID hard-fail.
for var in ("APPLE_ID", "APPLE_TEAM_ID", "APPLE_APP_SPECIFIC_PASSWORD"):
assert var in src, (
f"build.sh notarization block must reference {var} so it "
f"is gated on the full credential set"
)
def test_build_sh_stapler_failure_is_soft(self):
"""`xcrun stapler staple` must be wrapped in an `if/then/else`
(or paired with `||`) so a transient stapler failure becomes a
warning instead of aborting the build under `set -e`.
Apple's stapler service can fail intermittently after a successful
`notarytool submit` (CDN propagation lag, transient 5xx). A
signed-and-notarized-but-unstapled DMG is still functional —
Gatekeeper fetches the ticket online on first launch — so a
stapler hiccup must not delete the macOS artifact from the
release.
"""
src = _read("build.sh")
# Find the stapler invocation and check it is inside an `if` head
# (i.e. `if xcrun stapler staple ...; then`) OR followed by `||`.
# The simplest robust check: locate the line, then verify either
# (a) it begins with `if ` after stripping leading whitespace, or
# (b) it ends with ` || ...` style continuation.
# Only inspect actual code lines — strip both whole-line bash comments
# (`# …`) and inline trailing comments (`code # …`) before testing.
stapler_lines = []
for raw in src.splitlines():
code = raw.split("#", 1)[0]
if "xcrun stapler staple" in code:
stapler_lines.append(code)
assert stapler_lines, (
"build.sh must call `xcrun stapler staple` (notarization step)"
)
for line in stapler_lines:
stripped = line.strip()
wrapped_in_if = stripped.startswith("if ") and stripped.endswith("; then")
soft_or = "||" in stripped
assert wrapped_in_if or soft_or, (
"build.sh `xcrun stapler staple` invocation must be guarded "
"(`if xcrun stapler staple ...; then ... else WARN ... fi` "
"or `xcrun stapler staple ... || echo WARN`) so a transient "
"stapler failure does not abort the build under `set -e` and "
f"silently drop the macOS DMG. Offending line: {stripped!r}"
)

View file

@ -114,15 +114,24 @@ def test_styles_cover_chat_header_controls_and_grouped_cards():
def test_chat_floating_overlays_have_readable_glass_backing():
"""v5: floating overlays at the top and bottom of the chat fade
fully to transparent at the inner edge (no visible step). The
blur is masked in lockstep so the glass effect does not create
its own hard border. Status badge and attachment badge keep
top header still paints its own gradient + mask in step with
the blur (one layer with blur). The bottom fade-to-zero contract
moved out of `#chat-input-area`'s background and onto a dedicated
sibling `.chat-bottom-fade` layer (z-index < input dock) so the
textarea is no longer optically swallowed by the dense end of the
gradient — see test_chat_bottom_fade_is_separate_layer below for
the layering invariant. Status badge and attachment badge keep
their solid backings (those are inline pills, not scrim layers).
"""
css = _read("web/style.css")
header = re.search(r"\.chat-page-header\s*\{(?P<body>[^}]+)\}", css, re.S).group("body")
status = re.search(r"\.status-badge\s*\{(?P<body>[^}]+)\}", css, re.S).group("body")
input_area = re.search(r"#chat-input-area\s*\{(?P<body>[^}]+)\}", css, re.S).group("body")
# There may be multiple `.chat-bottom-fade { … }` rules (base + mobile
# @media override). The base rule is the first match and is the one
# that owns the gradient; the mobile rule only adjusts height.
fade = re.search(r"\.chat-bottom-fade\s*\{(?P<body>[^}]+)\}", css, re.S).group("body")
fade_all_bodies = re.findall(r"\.chat-bottom-fade\s*\{([^}]+)\}", css, re.S)
attach = re.search(r"\.attach-badge\s*\{(?P<body>[^}]+)\}", css, re.S).group("body")
# Header: glass blur active, gradient ENDS at fully-transparent
@ -132,12 +141,31 @@ def test_chat_floating_overlays_have_readable_glass_backing():
assert "rgba(13, 11, 15, 0.00) 100%" in header
assert "mask-image:" in header
# Bottom input dock: same fade-to-zero contract.
assert "rgba(13, 11, 15, 0.00) 100%" in input_area
assert "mask-image:" in input_area
# Bottom fade layer (base rule): same fade-to-zero contract on a
# dedicated layer.
assert "rgba(13, 11, 15, 0.00) 100%" in fade
assert "linear-gradient" in fade
# No `mask-image` is needed because this layer is intentionally
# blur-less — it is a plain pointer-events:none gradient that sits below
# the input dock. The no-blur invariant is asserted on EVERY captured
# `.chat-bottom-fade { … }` rule (base + any `@media` overrides) so a
# regression that adds `backdrop-filter` only inside the mobile media
# block (and would silently slip past a base-only assertion) is caught.
# If a future edit needs blur on this layer, also add a coordinating
# mask-image so the blur fades in step with the gradient — see the
# .chat-page-header pattern for the canonical idiom.
assert fade_all_bodies, ".chat-bottom-fade rule must exist"
for idx, body in enumerate(fade_all_bodies):
assert "backdrop-filter" not in body, (
f".chat-bottom-fade rule #{idx} (counting base + @media overrides) "
f"must remain blur-less. If a future edit needs blur on this "
f"layer, also add a coordinating mask-image (see "
f".chat-page-header). Offending rule body: {body.strip()[:200]!r}"
)
# Inline pills keep their solid backings — the test only enforces
# the fade-to-zero invariant on the scrim layers (header / dock).
# the fade-to-zero invariant on the scrim layers (header / fade).
assert "backdrop-filter: blur(8px)" in status
assert "rgba(26, 21, 32, 0.78)" in status
assert "backdrop-filter: blur(8px)" in attach
@ -1054,3 +1082,115 @@ def test_sync_history_sweep_skips_invisible_completed_cards():
"The final sweep guard must also check ts.completed so in-progress tasks "
"without cardVisible are still appended"
)
# ─── Autocorrect / spellcheck suppression on #chat-input ────────────────
def test_chat_input_disables_autocorrect():
"""#chat-input textarea must disable browser autocorrect/spellcheck/autocapitalize.
These attributes prevent the browser from silently rewriting code,
identifiers, slash-commands, and other technical input. Test asserts
each attribute by literal substring match against the textarea template
string in chat.js (no JSDOM — chat.js builds its own template at runtime).
"""
source = _read("web/modules/chat.js")
assert 'id="chat-input"' in source, "chat-input textarea must exist"
assert 'autocorrect="off"' in source, (
"chat-input textarea must set autocorrect='off'"
)
assert 'autocapitalize="off"' in source, (
"chat-input textarea must set autocapitalize='off'"
)
assert 'spellcheck="false"' in source, (
"chat-input textarea must set spellcheck='false'"
)
# ─── Clipboard image paste handler ──────────────────────────────────────
def test_clipboard_paste_handler_exists():
"""chat.js must register a `paste` listener that intercepts image/* clipboard
items and routes them through the same staging path the paperclip uses.
Verified by literal substring assertions: the listener registration, the
image/* MIME guard, the `pendingAttachment` set, and the `clipboard-`
filename prefix. No DOM execution — these strings are stable contract
surface for the feature.
"""
source = _read("web/modules/chat.js")
assert (
"addEventListener('paste'" in source
or 'addEventListener("paste"' in source
), (
"chat.js must register a paste event listener for clipboard image support"
)
assert "image/" in source, (
"paste handler must guard on image/* MIME type"
)
assert "pendingAttachment" in source, (
"paste handler must set pendingAttachment for the staged image"
)
assert "clipboard-" in source, (
"paste handler must generate a clipboard-prefixed filename"
)
# ─── Bottom-fade gradient layer is separate from #chat-input-area ───────
def test_chat_bottom_fade_is_separate_layer():
"""Bottom scroll-under fade must live on a dedicated `.chat-bottom-fade`
layer with `pointer-events: none` and a z-index strictly below
`#chat-input-area` (which is z-index 5). This ensures the textarea is
never optically swallowed by the dense bottom of the gradient.
Verified at the CSS source level (no rendered-DOM check) so the test is
deterministic and robust to layout changes that don't affect z-order.
"""
css = _read("web/style.css")
# The dedicated layer must be defined.
assert ".chat-bottom-fade {" in css, (
".chat-bottom-fade rule must exist as a dedicated layer (not painted "
"as a background on #chat-input-area)"
)
# Extract the .chat-bottom-fade rule body and assert pointer-events:none + z-index < 5.
fade_match = re.search(
r"\.chat-bottom-fade\s*\{([^}]*)\}",
css,
)
assert fade_match, ".chat-bottom-fade rule body must be parseable"
fade_body = fade_match.group(1)
assert "pointer-events" in fade_body and "none" in fade_body, (
".chat-bottom-fade must set pointer-events: none so it never blocks input"
)
z_match = re.search(r"z-index\s*:\s*(\d+)", fade_body)
assert z_match, ".chat-bottom-fade must declare an explicit z-index"
z_index = int(z_match.group(1))
assert z_index < 5, (
f".chat-bottom-fade z-index must be < 5 (got {z_index}) so it sits below "
f"#chat-input-area (z-index 5)"
)
# #chat-input-area must NOT carry the gradient background anymore.
input_area_match = re.search(
r"#chat-input-area\s*\{([^}]*)\}",
css,
)
assert input_area_match, "#chat-input-area rule must be parseable"
input_area_body = input_area_match.group(1)
# We tolerate other `background:` properties (none expected today) but
# specifically forbid linear-gradient bleeding through the input dock.
assert "linear-gradient" not in input_area_body, (
"#chat-input-area must not paint the bottom-fade gradient as its own "
"background — that is what made the textarea optically sink. Move the "
"gradient to the dedicated .chat-bottom-fade layer."
)
# And the DOM template in chat.js must include the dedicated fade element.
chat_js = _read("web/modules/chat.js")
assert 'class="chat-bottom-fade"' in chat_js, (
"chat.js page-chat template must include <div class=\"chat-bottom-fade\"> "
"as a sibling of #chat-input-area"
)

View file

@ -244,7 +244,7 @@ def test_checkout_and_reset_prefers_managed_remote_ref(monkeypatch, tmp_path):
"_read_managed_repo_meta",
lambda: {
"managed_remote_name": "managed",
"managed_remote_branch": "ouroboros-three-layer",
"managed_remote_branch": "ouroboros",
"managed_remote_stable_branch": "ouroboros-stable",
},
)
@ -264,7 +264,7 @@ def test_checkout_and_reset_prefers_managed_remote_ref(monkeypatch, tmp_path):
def fake_run(cmd, cwd=None, capture_output=False, text=False, check=False):
calls.append(cmd)
if cmd == ["git", "rev-parse", "--verify", "managed/ouroboros-three-layer"]:
if cmd == ["git", "rev-parse", "--verify", "managed/ouroboros"]:
return subprocess.CompletedProcess(cmd, 0, stdout="remote-sha\n", stderr="")
if cmd[:4] == ["git", "checkout", "-B", "ouroboros"]:
return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
@ -280,7 +280,7 @@ def test_checkout_and_reset_prefers_managed_remote_ref(monkeypatch, tmp_path):
assert ok
assert message == "ok"
assert ["git", "checkout", "-B", "ouroboros", "managed/ouroboros-three-layer"] in calls
assert ["git", "checkout", "-B", "ouroboros", "managed/ouroboros"] in calls
assert saved_state["current_branch"] == "ouroboros"
assert saved_state["current_sha"] == "fedcba"
@ -313,7 +313,7 @@ def test_collect_repo_sync_state_prefers_managed_remote(monkeypatch):
"_read_managed_repo_meta",
lambda: {
"managed_remote_name": "managed",
"managed_remote_branch": "ouroboros-three-layer",
"managed_remote_branch": "ouroboros",
},
)
monkeypatch.setattr(git_ops, "_has_remote", lambda name=None: name in (None, "managed"))
@ -323,7 +323,7 @@ def test_collect_repo_sync_state_prefers_managed_remote(monkeypatch):
return 0, "ouroboros", ""
if cmd == ["git", "status", "--porcelain"]:
return 0, "", ""
if cmd == ["git", "log", "--oneline", "managed/ouroboros-three-layer..HEAD"]:
if cmd == ["git", "log", "--oneline", "managed/ouroboros..HEAD"]:
return 0, "abc123 local commit\n", ""
raise AssertionError(cmd)
@ -347,7 +347,7 @@ def test_checkout_and_reset_keeps_bundled_sha_on_first_managed_bootstrap(monkeyp
"_read_managed_repo_meta",
lambda: {
"managed_remote_name": "managed",
"managed_remote_branch": "ouroboros-three-layer",
"managed_remote_branch": "ouroboros",
"source_sha": "bundle123",
},
)

View file

@ -78,7 +78,7 @@ def _make_bundle_source(tmp_path):
_run(["git", "init"], cwd=src)
_run(["git", "config", "user.name", "Test"], cwd=src)
_run(["git", "config", "user.email", "test@example.com"], cwd=src)
_run(["git", "checkout", "-b", "ouroboros-three-layer"], cwd=src)
_run(["git", "checkout", "-b", "ouroboros"], cwd=src)
_run(["git", "remote", "add", "origin", "https://github.com/joi-lab/ouroboros-desktop.git"], cwd=src)
(src / "VERSION").write_text("4.50.0-rc.2\n", encoding="utf-8")
(src / "server.py").write_text("print('bundle-v1')\n", encoding="utf-8")
@ -123,7 +123,7 @@ def test_ensure_managed_repo_clones_from_embedded_bundle(tmp_path):
assert (repo_dir / "server.py").read_text(encoding="utf-8") == "print('bundle-v1')\n"
assert _git_output(repo_dir, "branch", "--show-current") == "ouroboros"
meta = bootstrap.load_repo_manifest(repo_dir)
assert meta["managed_remote_branch"] == "ouroboros-three-layer"
assert meta["managed_remote_branch"] == "ouroboros"
assert meta["managed_local_branch"] == "ouroboros"
assert meta["release_tag"] == "v4.50.0-rc.2"
assert meta["bundle_sha256"]

View file

@ -0,0 +1,175 @@
"""
Provider integration tests — real API calls to verify each LLM provider works.
These tests are marked with @pytest.mark.integration and excluded from the
default pytest run via pyproject.toml addopts. They run only on:
- main / ouroboros / ouroboros-stable push (CI Tier 2.5)
- workflow_dispatch (manual)
- tag push (v*)
Each test is individually skipped when its API key is absent, so the job
stays green even if only a subset of keys is configured.
`LLMClient.chat()` returns a `(msg_dict, usage_dict)` tuple since v4.44.0.
The shared assertion below also handles the legacy flat-dict shape so tests
do not need to track the underlying client refactor.
"""
import os
import pytest
# Skip the entire module during routine pytest runs that use addopts -m "not integration".
# The mark also works as a per-test filter.
integration = pytest.mark.integration
def _get_llm_client():
"""Lazy import to avoid breaking collection when ouroboros is not installed."""
from ouroboros.llm import LLMClient
return LLMClient()
def _assert_basic_response(result, expected_provider=None):
"""Shared assertion: non-empty reply, token usage present.
In v4.44.0+ LLMClient.chat() returns a (msg_dict, usage_dict) tuple,
not a flat dict. Handle both shapes for forward compatibility.
"""
if isinstance(result, tuple):
msg, usage = result
else:
msg, usage = result, result.get("usage", {}) if isinstance(result, dict) else {}
text = ""
if isinstance(msg, dict):
text = msg.get("content", "") or ""
# Anthropic returns content as a list of typed blocks instead of a string.
if isinstance(text, list):
text = " ".join(
b.get("text", "") for b in text if isinstance(b, dict)
)
assert text, f"Empty response from LLM: {result}"
assert isinstance(usage, dict), f"Usage is not a dict: {type(usage)}"
assert usage.get("prompt_tokens", 0) > 0, f"No prompt_tokens in usage: {usage}"
assert usage.get("completion_tokens", 0) > 0, f"No completion_tokens in usage: {usage}"
if expected_provider:
resolved = usage.get("provider", "") or usage.get("resolved_model", "") or ""
assert expected_provider.lower() in resolved.lower(), (
f"Expected provider '{expected_provider}' in resolved model, "
f"got '{resolved}'"
)
@integration
@pytest.mark.skipif(
not os.environ.get("OPENROUTER_API_KEY"),
reason="OPENROUTER_API_KEY not set",
)
def test_openrouter_basic_chat():
"""Verify OpenRouter responds to a minimal chat request."""
client = _get_llm_client()
result = client.chat(
messages=[{"role": "user", "content": "Respond with exactly: OK"}],
model="anthropic/claude-sonnet-4.6",
)
_assert_basic_response(result, expected_provider="openrouter")
@integration
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set",
)
def test_openai_direct_basic_chat():
"""Verify official OpenAI direct routing works."""
client = _get_llm_client()
result = client.chat(
messages=[{"role": "user", "content": "Respond with exactly: OK"}],
model="openai::gpt-4o-mini",
)
_assert_basic_response(result, expected_provider="openai")
@integration
@pytest.mark.skipif(
not os.environ.get("ANTHROPIC_API_KEY"),
reason="ANTHROPIC_API_KEY not set",
)
def test_anthropic_direct_basic_chat():
"""Verify direct Anthropic routing works."""
client = _get_llm_client()
result = client.chat(
messages=[{"role": "user", "content": "Respond with exactly: OK"}],
model="anthropic::claude-sonnet-4-6",
)
_assert_basic_response(result, expected_provider="anthropic")
# Isolation tests: clear competing provider keys so LLMClient can only route
# through the single provider under test.
_COMPETING_KEYS = [
"OPENROUTER_API_KEY",
"OPENAI_API_KEY",
"OPENAI_BASE_URL",
"OPENAI_COMPATIBLE_API_KEY",
"OPENAI_COMPATIBLE_BASE_URL",
"CLOUDRU_FOUNDATION_MODELS_API_KEY",
"ANTHROPIC_API_KEY",
]
@integration
@pytest.mark.skipif(
not os.environ.get("OPENROUTER_API_KEY"),
reason="OPENROUTER_API_KEY not set",
)
def test_openrouter_isolation(monkeypatch):
"""OpenRouter works when it is the only configured provider."""
for key in _COMPETING_KEYS:
if key != "OPENROUTER_API_KEY":
monkeypatch.delenv(key, raising=False)
client = _get_llm_client()
result = client.chat(
messages=[{"role": "user", "content": "Say hello"}],
model="anthropic/claude-sonnet-4.6",
)
_assert_basic_response(result)
@integration
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set",
)
def test_openai_direct_isolation(monkeypatch):
"""OpenAI direct works when it is the only configured provider."""
for key in _COMPETING_KEYS:
if key != "OPENAI_API_KEY":
monkeypatch.delenv(key, raising=False)
client = _get_llm_client()
result = client.chat(
messages=[{"role": "user", "content": "Say hello"}],
model="openai::gpt-4o-mini",
)
_assert_basic_response(result)
@integration
@pytest.mark.skipif(
not os.environ.get("ANTHROPIC_API_KEY"),
reason="ANTHROPIC_API_KEY not set",
)
def test_anthropic_direct_isolation(monkeypatch):
"""Anthropic direct works when it is the only configured provider."""
for key in _COMPETING_KEYS:
if key != "ANTHROPIC_API_KEY":
monkeypatch.delenv(key, raising=False)
client = _get_llm_client()
result = client.chat(
messages=[{"role": "user", "content": "Say hello"}],
model="anthropic::claude-sonnet-4-6",
)
_assert_basic_response(result)

View file

@ -30,5 +30,5 @@ def test_ci_build_job_exports_release_tag_and_fetches_full_history():
workflow = _workflow()
assert "OUROBOROS_RELEASE_TAG: ${{ github.ref_name }}" in workflow
assert "OUROBOROS_MANAGED_SOURCE_BRANCH: ouroboros-three-layer" in workflow
assert "OUROBOROS_MANAGED_SOURCE_BRANCH: ouroboros" in workflow
assert "fetch-depth: 0" in workflow

View file

@ -68,6 +68,7 @@ export function initChat({ ws, state, updateUnreadBadge }) {
<span id="chat-status" class="status-badge offline">Connecting...</span>
</div>
<div id="chat-messages"></div>
<div class="chat-bottom-fade" aria-hidden="true"></div>
<div id="chat-input-area">
<div id="chat-attachment-preview" class="chat-attachment-preview"></div>
<div class="chat-input-wrap">
@ -75,7 +76,7 @@ export function initChat({ ws, state, updateUnreadBadge }) {
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
</button>
<input type="file" id="chat-file-input" class="chat-file-input-hidden" accept="*/*">
<textarea id="chat-input" placeholder="Message Ouroboros..." rows="1"></textarea>
<textarea id="chat-input" placeholder="Message Ouroboros..." rows="1" autocorrect="off" autocapitalize="off" spellcheck="false"></textarea>
<div class="chat-send-group">
<button class="chat-send-inline" id="chat-send" title="Send message">Send</button>
<button class="chat-send-chevron" id="chat-send-chevron" type="button" title="More send options" aria-label="More send options">
@ -111,14 +112,10 @@ export function initChat({ ws, state, updateUnreadBadge }) {
const attachmentPreview = document.getElementById('chat-attachment-preview');
let pendingAttachment = null;
attachBtn.addEventListener('click', () => fileInput.click());
// Stage the selected File object locally — no server upload until sendMessage().
// This avoids orphan files, race conditions with fast-send, and network usage for unsent files.
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
// Shared stager: paperclip change handler AND clipboard paste both go through here
// so the attachment badge / removal UI / upload-on-Send semantics are identical.
function stagePendingFile(file) {
if (!file) return;
fileInput.value = '';
pendingAttachment = { file, display_name: file.name };
attachmentPreview.classList.add('visible');
attachmentPreview.innerHTML = `
@ -135,6 +132,43 @@ export function initChat({ ws, state, updateUnreadBadge }) {
attachmentPreview.innerHTML = '';
requestAnimationFrame(() => updateMessagesPadding());
});
}
attachBtn.addEventListener('click', () => fileInput.click());
// Stage the selected File object locally — no server upload until sendMessage().
// This avoids orphan files, race conditions with fast-send, and network usage for unsent files.
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
fileInput.value = '';
stagePendingFile(file);
});
// Clipboard image paste: scan clipboardData.items for image/*, wrap as File via
// getAsFile(), and route through the same stagePendingFile() path the paperclip
// uses. preventDefault() runs ONLY when an image is matched so non-image clipboard
// payloads (text, formatted text) still paste natively into the textarea. The
// generated filename uses a unix timestamp + the MIME-derived extension so each
// paste is a distinct attachment if the user pastes several in a row.
input.addEventListener('paste', (e) => {
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (let i = 0; i < items.length; i += 1) {
const item = items[i];
if (item && item.kind === 'file' && typeof item.type === 'string' && item.type.startsWith('image/')) {
const blob = item.getAsFile();
if (!blob) continue;
e.preventDefault();
const ext = (item.type.split('/')[1] || 'png').split(';')[0].trim() || 'png';
const ts = Date.now();
const safeBlob = blob instanceof File
? new File([blob], `clipboard-${ts}.${ext}`, { type: blob.type })
: new File([blob], `clipboard-${ts}.${ext}`, { type: item.type });
stagePendingFile(safeBlob);
return;
}
}
});
// Set to true during syncHistory pass 1 to suppress premature DOM insertion of

View file

@ -1425,6 +1425,32 @@ body {
}
}
/* Bottom scroll-under fade rendered by a DEDICATED sibling layer so it sits
visually BELOW #chat-input-area (which is z-index: 5) and never optically
swallows the textarea or the attachment badge. Earlier versions painted
this gradient as #chat-input-area's own background, which made the lower
edge of the input visually dissolve into the dense end of the gradient
(rgba(13, 11, 15, 0.92)). Now the gradient is on its own pointer-events:none
layer at z-index: 4. Height is 200px so it always covers the tallest
`#chat-input-area` state (attachment badge ~30px + fully-expanded textarea
~120px + padding 24px ≈ 174px) with a buffer. */
.chat-bottom-fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 200px;
pointer-events: none;
z-index: 4;
background: linear-gradient(
0deg,
rgba(13, 11, 15, 0.92) 0%,
rgba(13, 11, 15, 0.74) 35%,
rgba(13, 11, 15, 0.30) 78%,
rgba(13, 11, 15, 0.00) 100%
);
}
#chat-input-area {
display: flex;
flex-direction: column;
@ -1436,20 +1462,10 @@ body {
left: 0;
right: 0;
z-index: 5;
/* Bottom scroll-under fade. Reaches a fully-transparent stop at the
upper edge so the transcript blends into the input dock without a
hard border. Previous gradient bottomed at 0.26 alpha and made a
visible step against the page background. */
background: linear-gradient(
0deg,
rgba(13, 11, 15, 0.92) 0%,
rgba(13, 11, 15, 0.74) 35%,
rgba(13, 11, 15, 0.30) 78%,
rgba(13, 11, 15, 0.00) 100%
);
/* Mask the blur (when used) to fade in step with the gradient. */
-webkit-mask-image: linear-gradient(0deg, black 0%, black 60%, transparent 100%);
mask-image: linear-gradient(0deg, black 0%, black 60%, transparent 100%);
/* No background here on purpose. The bottom scroll-under fade is rendered
by .chat-bottom-fade (z-index: 4) which sits visually below this dock,
so the textarea's own frosted-glass background reads cleanly without
the gradient bleeding through it. */
}
.chat-input-wrap {
@ -3086,6 +3102,13 @@ body {
padding-bottom: max(16px, env(safe-area-inset-bottom, 0px));
}
/* Bottom-fade layer grows by the safe-area inset so the gradient still
fully covers the tallest expanded textarea state on iOS where the
home-indicator pushes the input area up. */
.chat-bottom-fade {
height: calc(200px + env(safe-area-inset-bottom, 0px));
}
/* Header is now in normal flow on mobile — remove the desktop 56px top
padding that was meant to clear the absolute-positioned header. */
#chat-messages {