diff --git a/.env.example b/.env.example index d23276eb8..61d874d55 100644 --- a/.env.example +++ b/.env.example @@ -76,12 +76,24 @@ SEARXNG_INSTANCE=http://localhost:8080 # Change this if another local service already uses 7000 (macOS AirPlay often does). # APP_PORT=7000 +# Optional HTTP address advertised in companion/mobile pairing codes. Set this +# when Docker would otherwise advertise a container address or loopback. Use a +# LAN or Tailscale IPv4 address, a single-label hostname, or an mDNS *.local +# name that the phone can reach. HTTPS and public hostnames are not supported +# by the current companion client. Do not include credentials, a path, query, +# or fragment. +# COMPANION_BASE_URL=http://192.168.1.50:7000 + # Development-only auth bypass for loopback requests. # Keep false for Docker, LAN, reverse proxy, and any shared deployment. # LOCALHOST_BYPASS=false -# Mark session cookies Secure. Set true when Odysseus is served through HTTPS -# by a trusted reverse proxy or private access gateway. +# Mark session cookies Secure. Left unset, this follows the request scheme: +# an HTTPS login gets a Secure cookie, a plain-HTTP one does not. Set true to +# force it on, or false to force it off while you still serve plain HTTP. +# Upgrading: this used to default to false. Drop a leftover SECURE_COOKIES=false +# from your .env unless you still need that escape hatch — it keeps HTTPS logins +# on a non-Secure cookie. # SECURE_COOKIES=true # Optional: pre-seed the first admin password during setup. @@ -151,6 +163,21 @@ SEARXNG_INSTANCE=http://localhost:8080 # Local HTTP setups may use the callback URL inferred by the application. # GOOGLE_OAUTH_REDIRECT_URI=https://your-domain.com/api/email/oauth/google/callback +# Origin the MCP OAuth callback is sent back to, for remote (Streamable HTTP) +# MCP servers that register it dynamically. Defaults to http://localhost:$APP_PORT, +# which is right only when you reach Odysseus directly on that port. Set it for +# HTTPS, reverse-proxy, hosted, and Docker installs — inside the container the +# app always listens on 7000 and cannot see the host port map, so the default is +# wrong there whenever APP_PORT is not 7000. +# +# Not for Google MCP servers. Those use Desktop App credentials, and Google only +# accepts loopback redirect URIs for that client type, so a public origin here is +# rejected with redirect_uri_mismatch. Leave it unset for a Google-only install: +# the loopback default is what Google wants, and remote users finish through the +# paste-back page, which never has to load the redirect. +# https://developers.google.com/identity/protocols/oauth2/native-app +# OAUTH_REDIRECT_BASE_URL=https://your-domain.com + # ============================================================ # Misc # ============================================================ @@ -189,6 +216,7 @@ SEARXNG_INSTANCE=http://localhost:8080 # ODYSSEUS_EMAIL_COMPOSE_UPLOAD_MAX_BYTES=26214400 # email compose attachment (25 MB) # ODYSSEUS_STT_MAX_AUDIO_BYTES=26214400 # speech-to-text audio (25 MB) # ODYSSEUS_ICS_MAX_BYTES=10485760 # calendar .ics import (10 MB) +# ODYSSEUS_TTS_CACHE_MAX_BYTES=524288000 # TTS cache (500 MB) # ============================================================ # Host Docker access (explicit opt-in) diff --git a/.gitattributes b/.gitattributes index 2db234ba7..8681aee12 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,6 +15,13 @@ docker/entrypoint.sh text eol=lf *.cmd text eol=crlf *.bat text eol=crlf +# Vendored third-party bundles in static/lib/ are published minified artifacts +# and must stay byte-identical to what npm ships — stripping trailing whitespace +# to satisfy `git diff --check` would desync them from the upstream release. Turn +# the whitespace check off for that tree instead, and keep the bundles out of +# GitHub's language statistics. +static/lib/** -whitespace linguist-vendored + # Binary assets — never normalize. *.png binary *.jpg binary diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 3834b79d6..acde630ef 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -26,6 +26,18 @@ body: - label: I am running the latest code from the `dev` branch (the default branch you get on clone, where fixes land first) and the bug still reproduces there. Please `git pull` the latest `dev` before filing. required: true + - type: input + id: revision + attributes: + label: Odysseus Revision + description: | + From the repository root (on the host when using Docker), run + `git show -s --abbrev=12 --format='%h (%cs)' HEAD` + and paste the output exactly. + placeholder: "1fef4929cf1d (2026-08-11)" + validations: + required: true + - type: dropdown id: install-method attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index ae8d18f3f..4ee603ee9 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -8,8 +8,8 @@ body: value: | **Before submitting:** search [open issues](https://github.com/odysseus-dev/odysseus/issues) and [discussions](https://github.com/odysseus-dev/odysseus/discussions) first. - The [roadmap](https://github.com/odysseus-dev/odysseus/blob/main/ROADMAP.md) is directional rather than a complete backlog. - Feature requests that duplicate an existing issue or accepted proposal may be closed as duplicates. + Feature requests that duplicate [ROADMAP.md](https://github.com/odysseus-dev/odysseus/blob/main/ROADMAP.md) + or an existing open issue will be closed as duplicates. If your idea needs community input before it becomes a concrete proposal, start a [discussion](https://github.com/odysseus-dev/odysseus/discussions/categories/ideas) instead. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 822229b35..c54bf8963 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -28,6 +28,7 @@ Fixes # - [ ] This PR targets `dev` - [ ] My changes are limited to the scope described above — no unrelated refactors or whitespace changes mixed in. - [ ] I actually ran the app (`docker compose up` or `uvicorn app:app`) and verified the change works end-to-end. Type-checks and unit tests are not enough. +- [ ] I did not run the app/runtime validation and stated that gap in **How to Test**. Leave this unchecked when the app-run box above is checked. ## How to Test diff --git a/.github/scripts/check-issue-description.js b/.github/scripts/check-issue-description.js index a76ca29ab..2c96de122 100644 --- a/.github/scripts/check-issue-description.js +++ b/.github/scripts/check-issue-description.js @@ -41,6 +41,14 @@ module.exports = async ({ github, context, core }) => { break; case 'bug': { + const revisionText = section('Odysseus Revision'); + if (!/^[0-9a-f]{12} \(\d{4}-\d{2}-\d{2}\)$/i.test(revisionText)) { + failures.push( + '**Odysseus Revision** — paste the 12-character commit SHA and date, ' + + 'for example `1fef4929cf1d (2026-08-11)`', + ); + } + if (!section('Install Method')) { failures.push('**Install Method** — select how you installed Odysseus'); } @@ -153,6 +161,16 @@ module.exports = async ({ github, context, core }) => { } } + const LABEL_BAD = 'needs more info'; + const LABEL_GOOD = 'ready for review'; + + // Closed issues are no longer awaiting review. + // This also prevents later edits to closed issues from restoring the label. + if (issue.state === 'closed') { + await dropLabel(LABEL_GOOD); + return; + } + // ── Find existing bot comment to update in-place ────────────────────────── const MARKER = ''; const { data: comments } = await github.rest.issues.listComments({ @@ -160,9 +178,6 @@ module.exports = async ({ github, context, core }) => { }); const existing = comments.find(c => c.user.type === 'Bot' && c.body.includes(MARKER)); - const LABEL_BAD = 'needs more info'; - const LABEL_GOOD = 'ready for review'; - if (failures.length === 0) { if (existing) { await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id }); diff --git a/.github/scripts/check-pr-description.js b/.github/scripts/check-pr-description.js index f5dabea5d..d817d453a 100644 --- a/.github/scripts/check-pr-description.js +++ b/.github/scripts/check-pr-description.js @@ -21,11 +21,11 @@ module.exports = async ({ github, context, core }) => { return strip(m?.[0].replace(new RegExp(`#+\\s+${heading}`, 'i'), '') ?? ''); } - const problems = []; + const descriptionProblems = []; // 1. Summary must be filled in. if (section('Summary').length < 20) { - problems.push('**Summary** is empty or too short — describe what changed and why.'); + descriptionProblems.push('**Summary** is empty or too short — describe what changed and why.'); } // 2. Linked Issue must reference a real issue. Accept a bare #NNN, a closing @@ -34,18 +34,18 @@ module.exports = async ({ github, context, core }) => { const linkedSection = section('Linked Issue'); const hasIssueRef = /#\d+\b/.test(linkedSection) || /\/issues\/\d+/.test(linkedSection); if (!linkedSection || !hasIssueRef) { - problems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.'); + descriptionProblems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.'); } // 3. At least one Type of Change box must be checked. const typeBlock = body.match(/##\s+Type of Change[\s\S]*?(?=\n##\s|$)/i)?.[0] ?? ''; if (!/- \[x\]/i.test(typeBlock)) { - problems.push('**Type of Change** — check at least one box.'); + descriptionProblems.push('**Type of Change** — check at least one box.'); } // 4. Duplicate-search checklist item must be checked. if (!/- \[x\] I searched/i.test(body)) { - problems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.'); + descriptionProblems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.'); } // 5. How to Test must contain enough real detail for a reviewer to act on. @@ -53,7 +53,83 @@ module.exports = async ({ github, context, core }) => { // code block — so we only require non-trivial content, not a specific shape. const howTo = section('How to Test'); if (howTo.length < 30) { - problems.push('**How to Test** — explain how a reviewer can verify this change. Numbered steps, the commands you ran, or a short code block all work — give a sentence or two of real detail (not just "tested locally").'); + descriptionProblems.push('**How to Test** — explain how a reviewer can verify this change. Numbered steps, the commands you ran, or a short code block all work — give a sentence or two of real detail (not just "tested locally").'); + } + + // Classify paths from GitHub's API. This workflow runs in the privileged base + // context, so it must never check out or execute code from the PR branch. + const changedFiles = await github.paginate(github.rest.pulls.listFiles, { + owner, repo, pull_number: prNum, per_page: 100, + }); + const changedPaths = changedFiles.map(file => file.filename); + + function isUiSensitivePath(filename) { + const path = filename.toLowerCase(); + return path.startsWith('static/') + || path.startsWith('templates/') + || /\.(?:html?|css|svg)$/.test(path); + } + + function isDocsOnlyPath(filename) { + const path = filename.toLowerCase(); + return /\.(?:md|mdx|rst|adoc|txt)$/.test(path) + || (path.startsWith('docs/') && !isUiSensitivePath(path)); + } + + function isRuntimeSensitivePath(filename) { + const path = filename.toLowerCase(); + if (isUiSensitivePath(path)) return false; + if (path.startsWith('tests/') || path.startsWith('.github/')) return false; + return /^(?:app\.py|routes\/|services\/|src\/|core\/|mcp_servers\/|scripts\/|docker\/)/.test(path) + || /^(?:dockerfile|docker-compose.*\.ya?ml|requirements(?:-optional)?\.txt|pyproject\.toml|setup\.py)$/.test(path) + || /\.(?:py|sh|ps1|bat)$/.test(path); + } + + let classification = 'tooling'; + if (changedPaths.some(isUiSensitivePath)) { + classification = 'UI-sensitive'; + } else if (changedPaths.some(isRuntimeSensitivePath)) { + classification = 'backend/runtime'; + } else if (changedPaths.length > 0 && changedPaths.every(isDocsOnlyPath)) { + classification = 'docs-only'; + } + + const appRan = /- \[x\]\s+I actually ran the app\b/i.test(body); + const appNotRun = /- \[x\]\s+I did not run the app\/runtime validation\b/i.test(body); + // Anchor on the wording, not the template's emphasis: a ticked box the author + // retyped without the surrounding ** renders identically on the PR page, so + // treating it as unchecked is invisible from their side. Matches the two + // attestations above, which already ignore formatting. + const screenshotChecked = /- \[x\]\s+[*_]{0,2}Screenshot or short clip[*_]{0,2}/i.test(body); + const screenshotSection = section('Screenshots / clips'); + const hasVisualEvidence = /!\[[^\]]*\]\([^)]+\)|<(?:img|video|source)\b[^>]*(?:src|href)=|https?:\/\/[^\s)]+/i.test(screenshotSection); + const evidenceGaps = []; + let needsRuntimeValidation = false; + let needsVisualEvidence = false; + + if (classification === 'backend/runtime' || classification === 'UI-sensitive') { + if (appRan && appNotRun) { + needsRuntimeValidation = true; + evidenceGaps.push('The app-run and explicit not-run boxes are both checked. Select the one state that is true.'); + } else if (!appRan) { + needsRuntimeValidation = true; + if (appNotRun) { + evidenceGaps.push('The author explicitly reports that app/runtime validation was not performed.'); + } else { + evidenceGaps.push('App/runtime validation is not author-attested. Check the run box only after running it, or check the explicit not-run box and describe the gap.'); + } + } + } + + if (classification === 'UI-sensitive') { + if (!screenshotChecked) { + needsVisualEvidence = true; + evidenceGaps.push('The screenshot/clip checkbox is not checked for this UI-sensitive change.'); + } + if (!hasVisualEvidence) { + needsVisualEvidence = true; + evidenceGaps.push('The Screenshots / clips section does not contain an actual attachment or link.'); + } } // ── Comment ────────────────────────────────────────────────────────────── @@ -62,22 +138,43 @@ module.exports = async ({ github, context, core }) => { }); const existing = comments.find(c => (c.body ?? '').includes(MARKER)); - if (problems.length === 0) { + if (descriptionProblems.length === 0 && evidenceGaps.length === 0) { if (existing) { await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id }); } } else { - const commentBody = [ - MARKER, - '⚠️ **PR description — action needed**', - '', - 'The following required sections are missing or incomplete. Please update the PR description to address them:', - '', - problems.map(p => `- ${p}`).join('\n'), + const commentLines = [MARKER]; + if (descriptionProblems.length > 0) { + commentLines.push( + '⚠️ **PR description — action needed**', + '', + 'The following required sections are missing or incomplete. Please update the PR description to address them:', + '', + descriptionProblems.map(problem => `- ${problem}`).join('\n'), + ); + } else { + commentLines.push( + '⚠️ **PR description is complete; validation evidence is still outstanding**', + '', + `Changed-file classification: **${classification}**.`, + ); + } + if (evidenceGaps.length > 0) { + commentLines.push( + '', + '**Author-reported runtime / visual state**', + '', + evidenceGaps.map(gap => `- ${gap}`).join('\n'), + '', + 'Checkboxes are author attestations. GitHub Actions results remain the execution evidence for CI; this check does not prove that a local command ran.', + ); + } + commentLines.push( '', '---', - '_This comment is deleted automatically once all sections are complete._', - ].join('\n'); + '_This comment updates automatically when the description or changed files change._', + ); + const commentBody = commentLines.join('\n'); if (existing) { await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody }); @@ -97,34 +194,47 @@ module.exports = async ({ github, context, core }) => { return true; } catch (e) { if (e.status === 404) return false; + if (e.status === 403) { + core.warning(`Could not inspect label "${name}" — token lacks label read access; skipping.`); + return false; + } throw e; } } - async function swapLabel(num, add, remove) { - if (await labelExists(add)) { + async function setLabel(name, wanted) { + if (wanted && await labelExists(name)) { try { - await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: [add] }); + await github.rest.issues.addLabels({ owner, repo, issue_number: prNum, labels: [name] }); } catch (e) { // Fail soft on a token that can't write labels so a label permission // problem never masks the actual description verdict. - if (e.status !== 403) throw e; - core.warning(`Could not add "${add}" — token lacks label write here; skipping.`); + if (e.status !== 403 && e.status !== 404) throw e; + core.warning(`Could not add "${name}" — label is unavailable or the token lacks label write access; skipping.`); } + } else if (wanted) { + core.warning(`Label "${name}" does not exist in the repo — skipping. Create it once to enable labelling.`); } else { - core.warning(`Label "${add}" does not exist in the repo — skipping. Create it once to enable labelling.`); - } - try { - await github.rest.issues.removeLabel({ owner, repo, issue_number: num, name: remove }); - } catch (e) { - if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e; + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: prNum, name }); + } catch (e) { + if (e.status !== 404 && e.status !== 410 && e.status !== 403) throw e; + } } } - if (problems.length === 0) { - await swapLabel(prNum, 'ready for review', 'needs work'); - } else { - await swapLabel(prNum, 'needs work', 'ready for review'); - core.setFailed(`PR description has ${problems.length} issue(s) — see bot comment for details.`); + const descriptionComplete = descriptionProblems.length === 0; + const evidenceComplete = evidenceGaps.length === 0; + const isDraft = Boolean(context.payload.pull_request.draft); + await setLabel( + 'ready for review', + descriptionComplete && evidenceComplete && !isDraft, + ); + await setLabel('needs work', !descriptionComplete); + await setLabel('needs runtime validation', needsRuntimeValidation); + await setLabel('needs visual evidence', needsVisualEvidence); + + if (!descriptionComplete) { + core.setFailed(`PR description has ${descriptionProblems.length} issue(s) — see bot comment for details.`); } }; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7d3659e8..e42c1a5d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, dev] pull_request: # Least privilege: none of the jobs write to the repo. @@ -103,10 +103,7 @@ jobs: python-tests: name: Python tests (pytest) runs-on: ubuntu-latest - # Informational for now: the suite has known flaky / environment-dependent - # failures (test isolation + embedding-model assertions). Tracked under the - # ROADMAP "fresh install smoke tests" item; make this required once green. - continue-on-error: true + # Make Python test validation authoritative for the configured scope. steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: diff --git a/.github/workflows/issue-description-check.yml b/.github/workflows/issue-description-check.yml index 52e9dddae..5ce6037f0 100644 --- a/.github/workflows/issue-description-check.yml +++ b/.github/workflows/issue-description-check.yml @@ -2,7 +2,7 @@ name: ci / issue description check on: issues: - types: [opened, edited, reopened] + types: [opened, edited, reopened, closed] permissions: issues: write diff --git a/.github/workflows/pr-description-check.yml b/.github/workflows/pr-description-check.yml index 53f0b5f50..32f78bede 100644 --- a/.github/workflows/pr-description-check.yml +++ b/.github/workflows/pr-description-check.yml @@ -5,7 +5,11 @@ on: # works on fork PRs. Safe here: the checkout pins to the base branch (no fork # code runs) and the scripts only read context.payload and call the GitHub API. pull_request_target: # zizmor: ignore[dangerous-triggers] - types: [opened, edited, synchronize, reopened, ready_for_review] + types: [opened, edited, synchronize, reopened, ready_for_review, converted_to_draft] + +concurrency: + group: pr-description-${{ github.event.pull_request.number }} + cancel-in-progress: true # Default-deny at the workflow level; each job opts into only the scopes it needs. # Note: modifying a PR's labels/comments needs pull-requests:write even though the @@ -59,12 +63,14 @@ jobs: check-mergeable: name: Flag unmergeable PRs + needs: check-description runs-on: ubuntu-latest permissions: pull-requests: write issues: write - # Skip bots: they open PRs programmatically and have their own process. - if: github.event.pull_request.user.type != 'Bot' + # Run after description validation failures, but never from an obsolete + # workflow run canceled by a newer PR event. + if: ${{ !cancelled() && github.event.pull_request.user.type != 'Bot' }} steps: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 94092c6ca..21045acfa 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -65,6 +65,16 @@ Vendored in `static/lib/` and served directly: | [jsPDF](https://github.com/parallax/jsPDF) (bundled in html2pdf) | PDF generation | MIT | | [html2canvas](https://github.com/niklasvh/html2canvas) (bundled in html2pdf) | DOM → canvas rasterization | MIT | | [node-qrcode](https://github.com/soldair/node-qrcode) (`qrcode.min.js`) | QR-code rendering (2FA setup) | MIT | +| [KaTeX](https://github.com/KaTeX/KaTeX) v0.16.22 (`katex/katex.min.{js,css}` + `katex/fonts/*.woff2`) | Math typesetting | MIT ([`licenses/KaTeX-MIT-LICENSE.txt`](licenses/KaTeX-MIT-LICENSE.txt)) | +| [Mermaid](https://github.com/mermaid-js/mermaid) v11.16.1 (`mermaid.min.js`) | Diagrams from text | MIT ([`licenses/Mermaid-MIT-LICENSE.txt`](licenses/Mermaid-MIT-LICENSE.txt)) | + +KaTeX and Mermaid are loaded on first use by `static/js/markdown.js` rather than +from `index.html`, so a session that renders no math and no diagram never fetches +either. Only the `.woff2` KaTeX fonts are shipped, matching `static/fonts/`; the +`.woff` and `.ttf` variants its stylesheet also lists are never requested by a +browser that supports `woff2`. The bundles are the published npm artifacts, +unmodified — `.gitattributes` turns the whitespace check off for `static/lib/` +so they can stay byte-identical to upstream. ## Front-end libraries loaded at runtime (CDN) @@ -72,8 +82,6 @@ Referenced from `cdn.jsdelivr.net` / `cdnjs.cloudflare.com` at runtime — not v | Library | Purpose | License | |---|---|---| -| [KaTeX](https://github.com/KaTeX/KaTeX) 0.16.22 | Math typesetting | MIT | -| [Mermaid](https://github.com/mermaid-js/mermaid) 11 | Diagrams from text | MIT | | [Pyodide](https://github.com/pyodide/pyodide) 0.27.5 | In-browser Python runtime | MPL-2.0 | | [PDFObject](https://github.com/pipwerks/PDFObject) 2.1.1 | Inline PDF embedding | MIT | diff --git a/README.md b/README.md index b790f4e2d..4cc48f0d4 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,3 @@ -# Odysseus -
Quick Start · Setup Guide · - Architecture · - Security · Contributing · Roadmap
@@ -55,26 +51,31 @@ Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration ## Demo -Explore the interface through the [interactive product tour](docs/index.html). +A full hover-to-play tour lives on the landing page: [`docs/index.html`](docs/index.html). ## Contributing -Help is welcome. The best entry points are fresh-install testing, provider setup bugs, mobile/editor polish, documentation, and small focused refactors. Read the [contributing guide](CONTRIBUTING.md), review the [public roadmap](ROADMAP.md), and browse the open [GitHub issues](https://github.com/odysseus-dev/odysseus/issues). +Help is welcome. The best entry points are fresh-install testing, provider setup bugs, mobile/editor polish, docs, and small focused refactors. See [CONTRIBUTING.md](CONTRIBUTING.md) and [ROADMAP.md](ROADMAP.md). ## Security -Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model or service ports publicly. Read the [security policy](SECURITY.md) and the [deployment security guidance](docs/setup.md#security-notes). +Odysseus is a self-hosted workspace with powerful local tools. Keep auth enabled, keep private data out of Git, and do not expose raw model/service ports publicly. + +- Keep `AUTH_ENABLED=true` for any network-accessible deployment. +- Keep `LOCALHOST_BYPASS=false` outside local development. + +Deployment details are in the [setup guide](docs/setup.md#security-notes). ## Star History - +