mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 12:18:39 +00:00
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:
parent
ce042704db
commit
adac2e0b4e
18 changed files with 1072 additions and 80 deletions
132
.github/workflows/ci.yml
vendored
132
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
35
README.md
35
README.md
File diff suppressed because one or more lines are too long
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
5.0.0
|
||||
5.1.0
|
||||
|
|
|
|||
83
build.sh
83
build.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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}) ==="
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
175
tests/test_provider_integration.py
Normal file
175
tests/test_provider_integration.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue