diff --git a/.env.example b/.env.example index 61d874d55..d23276eb8 100644 --- a/.env.example +++ b/.env.example @@ -76,24 +76,12 @@ 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. 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. +# Mark session cookies Secure. Set true when Odysseus is served through HTTPS +# by a trusted reverse proxy or private access gateway. # SECURE_COOKIES=true # Optional: pre-seed the first admin password during setup. @@ -163,21 +151,6 @@ 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 # ============================================================ @@ -216,7 +189,6 @@ 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 8681aee12..2db234ba7 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,13 +15,6 @@ 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 acde630ef..3834b79d6 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -26,18 +26,6 @@ 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 4ee603ee9..ae8d18f3f 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. - 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. + 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. 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 c54bf8963..822229b35 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -28,7 +28,6 @@ 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 2c96de122..a76ca29ab 100644 --- a/.github/scripts/check-issue-description.js +++ b/.github/scripts/check-issue-description.js @@ -41,14 +41,6 @@ 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'); } @@ -161,16 +153,6 @@ 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({ @@ -178,6 +160,9 @@ 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 d817d453a..f5dabea5d 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 descriptionProblems = []; + const problems = []; // 1. Summary must be filled in. if (section('Summary').length < 20) { - descriptionProblems.push('**Summary** is empty or too short — describe what changed and why.'); + problems.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) { - descriptionProblems.push('**Linked Issue** — add a reference like `Fixes #NNN`, a bare `#NNN`, or a link to the issue.'); + problems.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)) { - descriptionProblems.push('**Type of Change** — check at least one box.'); + problems.push('**Type of Change** — check at least one box.'); } // 4. Duplicate-search checklist item must be checked. if (!/- \[x\] I searched/i.test(body)) { - descriptionProblems.push('**Checklist** — check the duplicate-search box to confirm you searched existing issues and PRs.'); + problems.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,83 +53,7 @@ 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) { - 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.'); - } + 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").'); } // ── Comment ────────────────────────────────────────────────────────────── @@ -138,43 +62,22 @@ module.exports = async ({ github, context, core }) => { }); const existing = comments.find(c => (c.body ?? '').includes(MARKER)); - if (descriptionProblems.length === 0 && evidenceGaps.length === 0) { + if (problems.length === 0) { if (existing) { await github.rest.issues.deleteComment({ owner, repo, comment_id: existing.id }); } } else { - 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( + 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'), '', '---', - '_This comment updates automatically when the description or changed files change._', - ); - const commentBody = commentLines.join('\n'); + '_This comment is deleted automatically once all sections are complete._', + ].join('\n'); if (existing) { await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body: commentBody }); @@ -194,47 +97,34 @@ 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 setLabel(name, wanted) { - if (wanted && await labelExists(name)) { + async function swapLabel(num, add, remove) { + if (await labelExists(add)) { try { - await github.rest.issues.addLabels({ owner, repo, issue_number: prNum, labels: [name] }); + await github.rest.issues.addLabels({ owner, repo, issue_number: num, labels: [add] }); } 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 && e.status !== 404) throw e; - core.warning(`Could not add "${name}" — label is unavailable or the token lacks label write access; skipping.`); + if (e.status !== 403) throw e; + core.warning(`Could not add "${add}" — token lacks label write here; skipping.`); } - } else if (wanted) { - core.warning(`Label "${name}" does not exist in the repo — skipping. Create it once to enable labelling.`); } else { - 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; - } + 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; } } - 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.`); + 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.`); } }; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e42c1a5d0..f7d3659e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, dev] + branches: [main] pull_request: # Least privilege: none of the jobs write to the repo. @@ -103,7 +103,10 @@ jobs: python-tests: name: Python tests (pytest) runs-on: ubuntu-latest - # Make Python test validation authoritative for the configured scope. + # 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 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 5ce6037f0..52e9dddae 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, closed] + types: [opened, edited, reopened] permissions: issues: write diff --git a/.github/workflows/pr-description-check.yml b/.github/workflows/pr-description-check.yml index 32f78bede..53f0b5f50 100644 --- a/.github/workflows/pr-description-check.yml +++ b/.github/workflows/pr-description-check.yml @@ -5,11 +5,7 @@ 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, converted_to_draft] - -concurrency: - group: pr-description-${{ github.event.pull_request.number }} - cancel-in-progress: true + types: [opened, edited, synchronize, reopened, ready_for_review] # 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 @@ -63,14 +59,12 @@ jobs: check-mergeable: name: Flag unmergeable PRs - needs: check-description runs-on: ubuntu-latest permissions: pull-requests: write issues: write - # 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' }} + # Skip bots: they open PRs programmatically and have their own process. + if: 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 21045acfa..94092c6ca 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -65,16 +65,6 @@ 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) @@ -82,6 +72,8 @@ 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 4cc48f0d4..b790f4e2d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +# Odysseus +
Quick Start · Setup Guide · + Architecture · + Security · Contributing · Roadmap
@@ -51,31 +55,26 @@ Native installs, GPU notes, Windows/macOS instructions, HTTPS, and configuration ## Demo -A full hover-to-play tour lives on the landing page: [`docs/index.html`](docs/index.html). +Explore the interface through the [interactive product tour](docs/index.html). ## Contributing -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). +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). ## 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/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). +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). ## Star History - +